· 63 min read

Promon Shield Mobile

Reverse engineering Promon Shield Mobile

Abstract. This post reverse engineers Promon Shield, a commercial mobile anti-tamper, as deployed in Supercell’s Clash Royale on iOS. Shield ships as the scrollx framework and begins executing from +[PRMShieldEventManager load]. Its important functions are flattened into state machines, and a shared byte-generation primitive builds over 1,100 strings and 100 indirect call targets at runtime. Imports are reconstructed from three encoded pointer tables, one of them filled by a custom Mach-O export-trie resolver, and system calls are made through a single SVC 0x80 stub. The detections covered are parent PID, P_TRACED, and TTY anti-debugging; additive and XXH64 checksums over __text; code signature validation of the executables listed in an encrypted bundle manifest; C function and Objective-C hook detection; hash verification of core system libraries; and a scan of loaded images for known hooking frameworks. The 16-byte __ostekake segment serves as a handshake point between the detection core and its callback layer. On detection, Shield opens a signed report URL and then exits, or crashes deliberately at 0x123456789A00 with an encoded reason code in X1.

Promon Shield is a mobile anti-tamper created by Promon, a cybersecurity company located in Norway. Their Promon Shield offering protects major clients such as banks, streaming services, and mobile gaming companies. Today, we will be specifically looking at how Promon Shield is used to protect Clash Royale, which is a real-time strategy game created by Supercell.

Extracting

Apple encrypts iOS apps at the time of upload to the App Store using FairPlay DRM. At the time of download, Apple provides FairPlay metadata that is tied to your Apple ID. This metadata allows iOS to recover or unwrap the app specific FairPlay key material that is used to decrypt the protected executable. I won’t be going into much technical depth on FairPlay DRM in this post, however, it is important to note that you cannot just download Clash Royale from the App Store and throw it into IDA. Here is a general diagram on how iOS actually decrypts executables downloaded from the App Store.

FairPlay encryption applies to the executable on disk. Once the app is running, the pages the kernel has mapped are already plaintext. The analysis in this post was performed on a decrypted copy of the executable, recovered on my own jailbroken device from a copy of Clash Royale installed through the App Store. The general technique is well documented: the decrypted pages are read from the running process and written over the encrypted range of a copy of the original Mach-O, and cryptid in LC_ENCRYPTION_INFO_64 is set to 0 so the loader does not attempt to decrypt plaintext. The decrypted binary was used only for static analysis and is not distributed.

With the decrypted executable in hand, we can begin static analysis.

Initial Findings

Promon shield in Clash Royale is shipped as a Framework called scrollx. scroll is used internally by Clash Royale constantly, such as their bundle identifier, com.supercell.scroll. What does it mean? Development of Clash Royale began in 2014, and by 2015, the game was internally known as Scroll. So why scrollx? I don’t really know. It seems like Supercell just appends x to their primary protection framework across all of their applications.

Segment Layout

One of the first things I look at after loading any executable into IDA is its segments layout. Do you notice any segments that are irregular here?

Mach-O segment layout
Segment layout of scrollx in IDA. Note the 16-byte __ostekake segment.

I did.

What is __ostekake?

The segment is tiny:

; AREA __ostekake, DATA, ALIGN=3
01 DCB    1
08 DCB    8
01 DCB    1
02 DCB    2
03 DCB    3
00 DCB    0
00 DCB    0
01 DCB    1
00 DCB    0
00 DCB    0
00 DCB    0
00 DCB    0
00 DCB    0
00 DCB    0
00 DCB    0
00 DCB    0
; __ostekake    ends

And unsurprisingly, IDA shows no direct cross-references to any of this data. At first, I thought that this might be some sort of protection-time configuration set. Maybe these numbers control internal flags which control which features are enabled by the anti-tamper.

That turned out to be entirely wrong. __ostekake turned out to be doing something far more interesting. However, at this point in my investigation, all I knew was that Promon decided to add a custom 16 byte Mach-O segment, that nothing was directly referencing it, and that it was likely there for SOME reason.

We will return to __ostekake deeper into the analysis.

Obfuscation

I quickly realized that Promon Shield has extreme levels of obfuscation in important functions. Shield’s obfuscation is built around something extremely similar to control flow flattening (CFF).

Traditional CFF works by replacing a function’s normal control flow graph (CFG) with an artificial state machine. Instead of the function’s basic blocks branching or continuing to blocks that logically follow them, blocks are disconnected and then routed through a central dispatcher. This dispatcher is controlled by an opaque state variable which tells the dispatcher which block will be executed next. The original relationships between blocks still exist, but are not immediately visible to the viewer. In order to recover the original control flow, we must track the state variable and reconstruct how it transitions.

Consider a function:

int check(int x) {
    if (x > 10)
        x += 5;
    else
        x -= 5;

    return x * 2;
}

In disassembly, the control flow is trivially recognizable:

check(int):
        sub     sp, sp, #16
        str     w0, [sp, 12]
        ldr     w0, [sp, 12]
        cmp     w0, 10
        ble     .L2
        ldr     w0, [sp, 12]
        add     w0, w0, 5
        str     w0, [sp, 12]
        b       .L3
.L2:
        ldr     w0, [sp, 12]
        sub     w0, w0, #5
        str     w0, [sp, 12]
.L3:
        ldr     w0, [sp, 12]
        lsl     w0, w0, 1
        add     sp, sp, 16
        ret

We can clearly see that at entry, x arrives in w0, is spilled to the stack, and is compared against an immediate value of 10. The conditional branch ble .L2 then selects the subtraction path based on the result of the cmp, or otherwise falls through to the addition path.

If we conceptually apply control flow flattening, the situation changes dramatically.

int check(int x) {
    int state = 0;

    while (1) {
        switch (state) {
        case 0:
            state = (x > 10) ? 1 : 2;
            break;

        case 1:
            x += 5;
            state = 3;
            break;

        case 2:
            x -= 5;
            state = 3;
            break;

        case 3:
            x *= 2;
            state = 4;
            break;

        case 4:
            return x;
        }
    }
}

And the matching assembly is:

check(int):
        sub     sp, sp, #32
        str     w0, [sp, 12]
        str     wzr, [sp, 28]
.L11:
        ldr     w0, [sp, 28]
        cmp     w0, 4
        beq     .L2
        ldr     w0, [sp, 28]
        cmp     w0, 4
        bgt     .L11
        ldr     w0, [sp, 28]
        cmp     w0, 3
        beq     .L4
        ldr     w0, [sp, 28]
        cmp     w0, 3
        bgt     .L11
        ldr     w0, [sp, 28]
        cmp     w0, 2
        beq     .L5
        ldr     w0, [sp, 28]
        cmp     w0, 2
        bgt     .L11
        ldr     w0, [sp, 28]
        cmp     w0, 0
        beq     .L6
        ldr     w0, [sp, 28]
        cmp     w0, 1
        beq     .L7
        b       .L3
.L6:
        ldr     w0, [sp, 12]
        cmp     w0, 10
        ble     .L8
        mov     w0, 1
        str     w0, [sp, 28]
        b       .L3
.L8:
        mov     w0, 2
        str     w0, [sp, 28]
        b       .L3
.L7:
        ldr     w0, [sp, 12]
        add     w0, w0, 5
        str     w0, [sp, 12]
        mov     w0, 3
        str     w0, [sp, 28]
        b       .L3
.L5:
        ldr     w0, [sp, 12]
        sub     w0, w0, #5
        str     w0, [sp, 12]
        mov     w0, 3
        str     w0, [sp, 28]
        b       .L3
.L4:
        ldr     w0, [sp, 12]
        lsl     w0, w0, 1
        str     w0, [sp, 12]
        mov     w0, 4
        str     w0, [sp, 28]
        b       .L3
.L2:
        ldr     w0, [sp, 12]
        b       .L12
.L3:
        b       .L11
.L12:
        add     sp, sp, 32
        ret

We can clearly still reconstruct control flow, but notice how we must now reason about the value of state across several dispatcher iterations. The conditional, x > 10, is no longer directly selecting an arithmetic block in the CFG of the function. Instead, a state is selected, that state returns to the dispatcher, and the dispatcher eventually executes the selected block.

Semantically, almost nothing of the program has changed. However, structurally, the function is entirely different.

For a processor, this isn’t a problem at all. But for a human looking at control flow, the experience is awful.

This is important, as Shield often strays from the canonical form of CFF. Many functions have no single large switch statement and no obvious local variable. The same state idea is spread across considerably more complicated transition and dispatch logic. Depending on the function, IDA may recover comparison trees, indirect routing, nested loops, and state transitions whose relationship to the original CFG is non-obvious.

Throughout this writeup, I’ll refer to this broadly as flattening, but remember that Shield’s flattening is a looser cousin of canonical CFF. Our toy state machine was being extremely generous by using incremental states, such as 0, 1, 2, 3, and 4. Promon is not nearly as polite.

bool __fastcall sub_3FEEC4(__int64 a1, __int64 a2, int a3)
{
  v21 = a3;
  v22 = a2;
  v25 = a1;
  v9 = 1635629636;
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        while ( v9 <= 1078557738 )
        {
          if ( v9 > 743826641 )
          {
            if ( v9 == 743826642 )
            {
              v4 = (bool *)&v20;
              v5 = v19;
              v24 = v19;
              v6 = v18;
              v3 = &v17;
              v7 = &v16;
              v8 = &v15;
              v13 = v22;
              v19[0] = v22;
              LOBYTE(v19[0]) = v21;
              sub_39E798(a1: (__int64 *)(v25 + 8), a2: "/", a3: v18);
              sub_880A4(a1: v18, a2: v13 + 8, a3: v18);
              destroyAndZeroBuffer(a1: v18);
              if ( v18[0] == 0 )
                LOBYTE(qword_A2D400[0]) = 0;
              v23 = sub_9690E0() == -1;
              v9 = 384688619;
            }
            else
            {
              *v4 = *v24 == 0;
              *(_DWORD *)v3 = 1;
              v9 = 1414707455;
            }
          }
          else
          {
            if ( v9 <= 337689873 )
            {
              sub_3FF3C0(a1: v6, a2: v8);
              destroyAndZeroBuffer(a1: v7);
              *v7 = *v8;
              *(_OWORD *)(v7 + 1) = *(_OWORD *)(v8 + 1);
              v8[1] = 0;
              v8[2] = 0;
              *v8 = 0;
              destroyAndZeroBuffer(a1: v8);
              goto LABEL_38;
            }
            if ( v23 )
              v11 = 754869210;
            else
              v11 = 2038284491;
            v9 = v11;
          }
        }
        if ( v9 > 1635629635 )
          break;
        if ( v9 > 1287421501 )
        {
          if ( v9 == 1414707455 )
            v12 = 2084944505;
          else
            v12 = v9;
          if ( v9 == 1287421502 )
            v9 = 1078557739;
          else
            v9 = v12;
        }
        else
        {
LABEL_38:
          *v4 = sub_90A74(a1: (unsigned __int8 **)v7, a2: *v5 + 32LL);
          *(_DWORD *)v3 = 1;
          destroyAndZeroBuffer(a1: v7);
          v9 = 1654283686;
        }
      }
      if ( v9 > 1668121712 )
        break;
      if ( v9 == 1654283686 )
        v10 = 2084944505;
      else
        v10 = v9;
      if ( v9 == 1635629636 )
        v9 = 743826642;
      else
        v9 = v10;
    }
    if ( v9 != 2038284491 )
      break;
    if ( *(_QWORD *)(*v5 + 48LL) != 0 )
    {
      *v7 = 0;
      v7[1] = 0;
      v7[2] = 0;
      if ( *(_DWORD *)*v5 == 3 )
        v9 = 134458408;
      else
        v9 = 1287421502;
    }
    else
    {
      *v4 = false;
      *(_DWORD *)v3 = 1;
      v9 = 2084944505;
    }
  }
  destroyAndZeroBuffer(a1: v6);
  return *v4;
}

Notice that v9 looks considerably like a dispatcher state, but its values consist of arbitrary 32-bit constants. Transitions are distributed throughout the function, and thus, IDA represents the function as several comparison trees and nested loops.

As an exercise to the reader, try and see if you can “emulate” the control flow in your head, and keep track of v9. Now imagine doing this for hundreds of functions! I’d like to say here that I wrote some complex lifter, lifted every function as LLVM IR, applied optimizations, and recovered near ground truth CFGs per function, but I did not. For this writeup, every function was either emulated using Unicorn, or manually traced.

Imports

Shield has pretty uninteresting imports. Functions are imported from /usr/lib/libSystem.B.dylib, /usr/lib/libobjc.A.dylib, and /usr/lib/libz.1.dylib. Shield also imports functions from the CoreFoundation and Foundation frameworks. Interestingly, there is a major lack of functions I would expect to be present.

On Apple’s Darwin-based platforms, such as iOS and macOS, executables are Mach-O binaries. Shared libraries which are intended to be loaded by another Mach-O image are called dynamic libraries, or dylibs. If you are more familiar with Windows, these are roughly analogous to a dll. At runtime, dyld manages a collection of loaded Mach-O images, which includes the main executable, frameworks, and dylibs.

The very first thing I would expect a competent Darwin anti-tamper to perform, is to iterate loaded shared libraries. On Darwin platforms, we can iterate these libraries using just a few functions:

#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <stdint.h>
#include <stdio.h>

int main(void) {
    uint32_t count = _dyld_image_count();

    for (uint32_t i = 0; i < count; i++) {
        const char *name = _dyld_get_image_name(i);
        const struct mach_header *header = _dyld_get_image_header(i);
        intptr_t slide = _dyld_get_image_vmaddr_slide(i);

        printf("[%u]\n", i);
        printf("  name:   %s\n", name ? name : "<unknown>");
        printf("  header: %p\n", (void *)header);
        printf("  slide:  0x%lx\n", (unsigned long)slide);
    }

    return 0;
}

If we knew every image that should exist in our process, we could build a whitelist and subsequently treat any unexpected Mach-O image as an extremely strong indication that additional code has been loaded into our process.

Shield has no import record for _dyld_get_image_name, _dyld_get_image_header, or _dyld_get_image_vmaddr_slide. My initial thought was that these symbols are dynamically resolved, which can be performed simply:

#include <dlfcn.h>
#include <stdint.h>

typedef const char *(*dyld_get_image_name_t)(uint32_t);

static dyld_get_image_name_t g_dyld_get_image_name;

static void resolve_dyld_apis(void)
{
    g_dyld_get_image_name =
        (dyld_get_image_name_t)dlsym(RTLD_DEFAULT, "_dyld_get_image_name");
}

Which can then be used later:

const char *name = g_dyld_get_image_name(0);

Shield does import dlsym. Like the rest of the binary’s modern dyld bindings, its imported pointer is represented through LC_DYLD_CHAINED_FIXUPS. However, the pointer that receives its address has no cross references.

Imports that display _dlsym symbol
The _dlsym import, with no cross-references to its pointer.

This leaves us with an interesting problem. Shield seems to require functionality that is absent from its imports, and the obvious mechanism to discover functions at runtime is supposedly unused!

Of course, given that this executable is heavily obfuscated, a missing xref in IDA only proves that nothing uses the import directly. Shield can still indirectly resolve the address of this import, and any other import that they may need.

Exports

Exports consist of seven Objective-C class symbols, PRMTestGetPromonPrivate(char const*) which is a no-op, and 59 TLS-related functions which setup virtual C++ class destructors and mutex initialization. There is really nothing of interest here.

Objective-C

Shield extensively uses Objective-C. Upon opening the executable in IDA, essentially all of the useful internal function names that survive are Objective-C methods. These functions are very revealing.

Named Objective-C functions
Objective-C methods, the only meaningful names that survive.

Specifically look at +[PRMShieldEventManager load]. This function is actually automatically called by the Objective-C runtime when scrollx is loaded into the process.

This is extremely valuable to us.

We already checked the exported TLS-related functions, and found that they perform zero interesting Shield related logic. scrollx also contains no ordinary __attribute__((constructor)) initialization functions, including __mod_init_func, and I could not find any additional Objective-C class or category implementing +load.

Therefore, +[PRMShieldEventManager load] is the earliest obvious Shield entrypoint we can identify statically, and importantly, the runtime invokes it for us.

For the first time, we have a named function, a known point of execution, and a path into Shield’s real initialization!

The good, the bad, and the dynamically resolved callsites?!

What if I gave you the power to teleport, but every time you wanted to, I forced you to solve some math to determine where you would land?

Welcome to one of the most annoying parts of Promon’s solution.

At first glance, +[PRMShieldEventManager load] is fairly simple.

void +[PRMShieldEventManager load]()
{
  v0 = sub_8BD564();
  v1 = sub_6B44C0(a1: v0);
  v2 = off_A42C80;
  v3 = sub_730644(a1: v9, a2: v1);
  v4 = v9[0];
  if ( v9[0] == 0 )
  {
    LOBYTE(qword_A2D400) = 0;
    v4 = &qword_A2D400;
  }
  (&v2[-*v4])(a1: v3);
  v5 = sub_81A4C(a1: v9);
  v6 = off_A42C78;
  v7 = sub_7306F8(a1: v9, a2: v5);
  v8 = v9[0];
  if ( v9[0] == 0 )
  {
    LOBYTE(qword_A2D400) = 0;
    v8 = &qword_A2D400;
  }
  (&v6[-*v8])(a1: v7);
  sub_81A4C(a1: v9);
}

Nothing to be concerned about, right? But what is (&v2[-*v4])(a1: v3); and (&v6[-*v8])(a1: v7);? v9 is populated via sub_730644, and the first eight bytes of its backing buffer are used to derive a call target relative to off_A42C80.

Let’s take a look at sub_730644. At the top-level, it initializes an object, requests a size of 8 bytes, gets its backing pointer, then populates that pointer via sub_72FDE8.

__int64 sub_730644@<X0>(__int64 **a1@<X8>)
{
  sub_82D24(result: a1);
  sub_84554(a1, a2: 8u);
  v2 = sub_7D6FC(a1);
  return sub_72FDE8(a1: v2);
}

sub_82D24 essentially zeroes the incoming structure.

// { ptr=0, capacity=0, size=0 }
_QWORD *sub_82D24(_QWORD *result)
{
  *result = 0;
  result[1] = 0;
  result[2] = 0;
  return result;
}

As mentioned, sub_84554 performs:

__int64 __fastcall sub_84554(_QWORD *a1, unsigned __int64 a2)
{
  v4 = a1[2]; 
  result = sub_85F28(a1, a2); // grow/set size
  if ( v4 < a2 )
    return sub_85F94(result: *a1 + v4, a2: a2 - v4);
  return result;
}

Where sub_85F94 performs:

__int64 __fastcall sub_85F94(__int64 result, unsigned __int64 a2)
{
  for (unsigned __int64 i = 0; i < a2; ++i )
    *(_BYTE *)(result + i) = 0;
  return result;
}

and sub_7D6FC returns the internal buffer.

__int64 *__fastcall sub_7D6FC(__int64 **a1)
{
  __int64 *result = *a1;
  if ( result == nullptr )
  {
    LOBYTE(qword_A2D400) = 0;
    return &qword_A2D400;
  }
  return result;
}

sub_72FDE8 is where things get considerably worse.

The function itself initializes an index to zero, then enters a chain of helper functions. Following the chain statically leads through hundreds of functions, largely because the actual arithmetic used to produce each byte is itself flattened.

Fortunately, we don’t need to understand every single function individually. Buried throughout this call tree are eight functions with the same effective structure:

byte = generate_byte(seed, *index);
buffer[(*index)++] = byte;

For example, the first function looks like:

__int64 sub_7321B0(__int64 buffer, _DWORD *index)
{
    char seed = sub_732240();
    char byte = sub_172770(seed, *index);

    *(_BYTE *)(buffer + (*index)++) = byte;
    return byte;
}

Its parent then immediately continues to another nearly identical writer:

__int64 sub_732204(__int64 buffer, _DWORD *index)
{
    sub_732248(buffer, index);
    return sub_73229C(buffer, index);
}

This pattern is repeated until all bytes are generated. In effect, the buffer is constructed like this:

void build_call_offset(uint8_t buffer[8])
{
    int index = 0;
    buffer[index] = generate_byte_0(index); index++;
    buffer[index] = generate_byte_1(index); index++;
    buffer[index] = generate_byte_2(index); index++;
    buffer[index] = generate_byte_3(index); index++;
    buffer[index] = generate_byte_4(index); index++;
    buffer[index] = generate_byte_5(index); index++;
    buffer[index] = generate_byte_6(index); index++;
    buffer[index] = generate_byte_7(index); index++;
}

The actual implementation is far more complicated than this example. generate_byte_N represents an enormous flattened arithmetic function and the writers themselves are separated by several layers of wrapper functions.

For our first example, we can use Unicorn to actually resolve the value filled by sub_730644.

After the complete resolver chain executes, the eight-byte buffer contains:

A4 4E 00 3D 00 00 00 00

On ARM64, which is little-endian, the resulting bytes represent: 0x000000003D004EA4 Looking back at the assembly for +[PRMShieldEventManager load], we can see that the call target is resolved via:

__data:0000000000A42C80 off_A42C80      DCQ 0x3D901DE0
MOV             W26, #0x60C49C87
ADRP            X8, #off_A42C80@PAGE
LDR             X24, [X8,#off_A42C80@PAGEOFF]
MOV             X8, X20
BL              sub_730644    ; writes internal buffer
LDR             X8, [X8]      ; loads computed internal buffer
ADD             X9, X24, X26
SUB             X8, X9, X8
SUB             X8, X8, X26
BLR             X8

As you can see:

ADD             X9, X24, X26
SUB             X8, X9, X8
SUB             X8, X8, X26

Computes the actual call target. Let’s do some algebra!

Let:

B = X24                  // value stored in off_A42C80
D = 0x3D004EA4           // generated 8-byte value
K = X26                  // obfuscating constant

If we substitute the symbolic values:

X9 = B + K

X8 = X9 - D
   = B + K - D

X8 = X8 - K
   = B + K - D - K
   = B - D

So X26 actually contributes NOTHING to our computed value! The entire sequence therefore is equivalent to:

target = 0x3D901DE0 - 0x3D004EA4
       = 0x008FCF3C

After all of that, Shield has dynamically reconstructed the image-relative-offset of the target function. At first, I thought that this was an insanely elaborate way to hide a few important call targets.

I was thinking far too small.

Remember sub_84554, the function used to resize the buffer before sub_72FDE8 filled it? That function has a whopping 1238 direct cross-references.

xrefs to sub_84554
1238 cross-references to the buffer-resize helper sub_84554.

Looking through these callers reveals that this exact same buffer construction primitive is used all over Shield. Indirect calls are only one customer of this resolution primitive. Promon uses the same mechanism to construct strings and values at runtime throughout the entire program.

This also explains something we noticed earlier; Shield’s static string and import surface looked very sparse because a significant amount of information is dynamically constructed through this system. I identified 100 indirect callsites and 1110 strings that are constructed this way.

In fact, every single string used to aid my understanding of Shield is produced by this mechanism. This system will be invoked extensively later in the writeup.

Shield Initialization

As shown earlier, the first thing +[PRMShieldEventManager load] does is call sub_8BD564, which I will refer to as bootstrapShieldRuntime.

Its very first call is to sub_8E6C8, which I will refer to as setupSyscallGate. This function stores the location of a syscall stub into a global:

ADRP            X8, #rawSyscallGate@PAGE
ADRL            X9, loc_9B30C4
STR             X9, [X8,#rawSyscallGate@PAGEOFF]
RET

loc_9B30C4 is the location of the syscall stub, and it is simply:

SVC             0x80
RET

SVC 0x80 on Darwin platforms tells the processor to switch to kernel mode and perform a syscall specified by the X16 register.

Shield uses this syscall stub throughout their runtime to bypass regular userspace libc wrappers.

Imagine you want to force the current program to close immediately, but didn’t want any direct cross-references, or even imports, to exit. You could simply perform:

MOV             X16, #1 ; syscall 1 is exit
MOV             X0, #0  ; exit code 0
ADRL            X17, rawSyscallGate
LDR             X17, [X17]
BLR             X17

Shield was extremely nice when using raw syscalls, as they all route through this shared global. By looking at cross-references, we can see several sites where they try to hide functionality.

Import Resolution

Remember the missing dyld imports, and how the dlsym pointer seemingly had no cross-references?

This monster at 0x8C567C explains exactly why.

Massive CFG
Control flow graph of sub_8C567C, a single 79KB flattened function.

This CFG is of one 79KB flattened function.

Buried under the obfuscation is an initializer for three private pointer tables. By the time this function finishes, entries in IDA that looked meaningless have become usable function pointers.

However, the three tables get there in uniquely different ways.

Finding Itself

Before reconstructing any addresses, Shield must first know where its own image is loaded.

It starts by capturing the runtime address of this massive function through PC-relative addressing:

0x8C6268: ADRL    X8, sub_8C567C
0x8C6270: MOV     X10, #0x548FCBC
0x8C6278: SUBS    X10, X8, X10
0x8C627C: MOV     X11, #0x80BC8EC
0x8C6284: ADD     X10, X10, X11

Later, it decodes a reference value from qword_A400E8, which is:

qword_A400E8    DCQ 0x554ABCCD64970C7A

The relevant part of this decoding sequence is:

; X14 = 0x554ABCCD640AB5FE
0x8D719C: ADRP X13, #qword_A400E8@PAGE
0x8D71A0: LDR X13, [X13,#qword_A400E8@PAGEOFF]
0x8D71A4: SUBS X13, X13, X14

Which gives us:

  0x554ABCCD64970C7A
- 0x554ABCCD640AB5FE
= 0x8C567C

Conveniently, that is this function’s address.

The remaining arithmetic cancels the constants that were added to its runtime address earlier. Let B be the address where the scrollx image was loaded. The complete operation becomes:

reference = 0x8C567C;
runtime_function_address = B + 0x8C567C;
image_base = runtime_function_address - reference; // image_base = B

So, this sequence is actually computing the image base of the scrollx Mach-O image.

Subtracting the addresses displayed in IDA would result in 0, as my configured IDA image-base is 0, but at runtime the PC-relative address includes the image’s actual load address.

This is effectively the same as performing _dyld_get_image_header(scrollx_image_index).

Another encoded global, qword_A400E0, decodes to a header address of zero. Adding that to image_base, gives Shield a pointer to its own Mach-O header.

Shield now has enough information to relocate its stored references and inspect its dependencies without needing to call any dyld API to locate itself.

Three tables

The tables begin at 0xA40100, 0xA40740, and 0xA40D80. They are adjacent, with 0x640 bytes reserved for each table. Since these tables consist of u64 entries, each table has capacity for 200 members.

Their roles are:

  • 0xA40100: image-relative offset of local code
  • 0xA40740: image-relative offset of an existing pointer slot
  • 0xA40D80: library ordinal and symbol-name offset

Each table uses a different constant, but shares the same decoding pattern:

decoded = encoded - (index + 1) * constant;

All of the following 64-bit arithmetic wraps modulo 2^64, matching the machine instructions.

The table index is part of the encoding. An entry cannot be decoded in isolation without knowing where it belongs.

Local code addresses

The first table is the simplest:

uint64_t rva =
    table[i] - (uint64_t)(i + 1) * 0xAB676042843BC1B0;

table[i] = image_base + rva;

The multiplication and subtraction are visible among the surrounding arithmetic:

; X11 = sign-extended (i + 1)

0x8CA0B0: MOV     X12, #0xAB676042843BC1B0
0x8CA0C0: MUL     X13, X11, X12
...
0x8CA0EC: SUBS    X8, X8, X13

For example, the first entry contains:

encoded = 0xAB67604284A9BA8C;
key     = 0xAB676042843BC1B0;

rva = encoded - key;
// 0x6DF8DC

The initializer replaces that encoded value with image_base + 0x6DF8DC.

Other entries decode directly to functions we can identify in the image. The entry at 0xA40258 resolves to image-relative offset 0x7F785C, the syscall-74 wrapper used for mprotect.

0x7F785C: MOV             X16, #0x4A
0x7F7860: STP             X17, X30, [SP,#var_10]!
0x7F7864: ADRL            X17, rawSyscallGate
0x7F786C: LDR             X17, [X17]
0x7F7870: BLR             X17

The entry at 0xA402A0 resolves to 0x8AE8D8, the syscall-1 exit wrapper.

0x8AE8D8: MOV             X16, #1
0x8AE8DC: STP             X17, X30, [SP,#var_10]!
0x8AE8E0: ADRL            X17, rawSyscallGate
0x8AE8E8: LDR             X17, [X17]
0x8AE8EC: BLR             X17

This table therefore supplies addresses of Shield’s own implementations and wrappers. An import-looking callsite can ultimately lead back into local code.

Copies of existing pointers

The second table initially looks almost identical:

uint64_t slot_rva =
    table[i] - (uint64_t)(i + 1) * 0x15FCDE38AA00E4B2;

table[i] = *(uint64_t *)(image_base + slot_rva);

The important difference is the final dereference.

The decoded address identifies a location containing a pointer. Shield reads that pointer and copies it into its private table:

0x8CE6D0: LDUR    X8, [X29,#var_90]
0x8CE6D4: LDR     X8, [X8]             ; reconstructed slot address
0x8CE6D8: LDR     X8, [X8]             ; pointer stored at that address
...
0x8CE6F8: STR     X8, [X11,X12,LSL#3] ; copy into private table

Take the entry I identified as free, at 0xA40830. It is index 30 in this table:

uint64_t slot_rva =
    table[30] - 31ULL * 0x15FCDE38AA00E4B2;
// 0xA003E0

table[30] = *(uint64_t *)(image_base + 0xA003E0);

The consuming stub is just three instructions:

; __imp_free is the private slot at image-relative offset 0xA40830.

0x9693A4: ADRP    X16, #__imp_free@PAGE
0x9693A8: LDR     X16, [X16,#__imp_free@PAGEOFF]
0x9693AC: BR      X16

The caller reaches this stub, the stub loads the reconstructed pointer, and BR X16 transfers control to the target. Because BR does not update the link register, the target returns directly to the stub’s caller.

This also answers our earlier question about dlsym.

The private entry at 0xA40858 decodes to source-slot image-relative offset 0xA00348, corresponding to the imported dlsym pointer. Shield can reach it by reconstructing the source address and copying its contents, even though IDA shows no ordinary direct reference to the original slot.

The missing xref was hiding an indirect use.

An additional consequence is that the private table holds a snapshot of each copied pointer. Changing the original slot afterward does not automatically change the copy. Don’t mistake this for an integrity check, though: an already modified source pointer could be copied just as easily.

Resolving exports by name

The third table does something more involved.

Its decoded entries are packed descriptors:

uint64_t descriptor =
    table[i] - (uint64_t)(i + 1) * 0xF06403348AF38D26;

uint32_t name_offset = descriptor >> 32;
uint32_t ordinal     = (uint32_t)descriptor;

The high 32 bits are extracted explicitly:

0x8C9DE4: LDR     X8, [X9]
0x8C9DE8: LDR     X8, [X8]
0x8C9DEC: LSR     X8, X8, #0x20
0x8C9DF0: LDR     X11, [X9,#8]
0x8C9DF4: STR     W8, [X11]

The library ordinal identifies a dependency through the owning image’s load commands. The name offset identifies a string within the encoded blob at 0xA413C4.

For the entry I identified as dladdr, at 0xA40DF0:

// Index 14:
uint64_t descriptor =
    0x15DC310E2445453BULL
    - 15ULL * 0xF06403348AF38D26ULL;

// descriptor = 0x000000FA00000001

uint32_t ordinal = 1;
uint32_t name_offset = 0xFA;

That is enough information to select a dependency and locate the symbol name. But the name is still encoded.

For this particular string blob, the decoder uses a much simpler transformation than the byte-generation machinery discussed earlier. After cancelling the arithmetic substitutions, it reduces to:

blob[j] = (uint8_t)(blob[j] - 0x53 * (j + 1));

Here, j is the offset within the shared blob. Decoding begins at the descriptor’s name_offset; the index does not restart at zero for each name.

The code writes the decoded byte back into the blob and then checks whether it is NULL:

0x8CD110: STRB    W8, [X11]
...
0x8CD11C: LDRSB   W8, [X8]
0x8CD120: SUBS    W8, W8, #0
0x8CD124: CSET    W8, EQ

Even the terminator is recognized after decoding.

With the name available, Shield passes the reconstructed image header, dependency ordinal, and name to sub_1BDD98:

0x8CB9DC: LDUR    X9, [X29,#var_78]
0x8CB9E0: LDR     X0, [X9]             ; image header

0x8CB9E4: LDR     X9, [X8,#0x28]
0x8CB9E8: LDR     W9, [X9]
0x8CB9EC: MOV     X1, X9               ; library ordinal

0x8CB9F0: LDR     X8, [X8,#0x20]
0x8CB9F4: LDR     X2, [X8]             ; decoded symbol name

0x8CB9F8: BL      sub_1BDD98

Following this call leads into a custom Mach-O export resolver.

It walks the owning image’s load commands to recover the selected dependency’s name, locates that image through dyld-cache lookup machinery, and traverses its export trie. The implementation includes its own ULEB128 decoder for the trie’s variable-length integers, as well as handling for symbols re-exported through another dependency.

In other words, Shield implements enough of Mach-O’s dynamic symbol-resolution machinery to resolve this class of imports itself.

This resolution path does not call dlsym. Shield imports dlsym elsewhere, but reconstructs this particular group of imports through its own parsing code.

The returned address then replaces the original descriptor:

0x8CC90C: BL      sub_1BDD98
...
0x8CC924: STR     X0, [X8]               ; save resolved address
0x8CC928: LDR     X8, [X9,#0x30]
0x8CC92C: LDR     X8, [X8]
...
0x8CC938: ADRL    X11, qword_A40D80
0x8CC940: STR     X8, [X11,X12,LSL#3]   ; overwrite descriptor

That same eight-byte entry now has a completely different meaning.

Before initialization, it contains encoded instructions for finding a symbol. After initialization, it contains the symbol’s address.

Cleaning up

Once a symbol has been resolved, Shield zeroes its decoded name:

0x8D6290: LDR     X10, [X9,#0x40]
0x8D6294: LDR     X10, [X10]
0x8D6298: STRB    WZR, [X10]

Other dispatcher blocks advance the cleanup cursor through the string.

The pointer survives, but the plaintext name used to obtain it does not.

This means the file and initialized process memory expose different information. On disk, we have encoded descriptors and encoded names. After initialization, the descriptors have become pointers and the recovered name bytes have been erased.

The function also manages memory permissions through the shared syscall mechanism established earlier. It uses a region-descriptor table at 0xA41680, whose entries have a 0x18-byte stride:

struct RegionDescriptor {
    uint64_t rva;
    uint64_t length;
    uint32_t protection;
    uint32_t unknown;
};

One set of calls requests read/write access:

mprotect(image_base + region.rva, region.length, 3);

Another applies the permission value stored in the descriptor:

mprotect(
    image_base + region.rva,
    region.length,
    region.protection
);

The region’s offset, length, and protection fields are then cleared. The exact regions and final permissions depend on the descriptor contents; these instructions alone do not establish that every table becomes read-only.

After all of this, the three reconstruction strategies converge on the same interface: a usable address stored in a private table, ready for a small stub to load and branch through.

Checks

We now know how Shield finds itself, how it resolves its imports, how it builds strings, and how it calls functions without leaving cross-references behind. That was a lot of work just to read the program. Now we finally get to see what Shield actually does with all of that machinery.

PPID

Every process on Darwin has a parent. When you tap on an app’s icon, iOS asks launchd to spawn it, and launchd is always PID 1. So under normal circumstances, a Clash Royale process should always have a parent process ID of 1.

If someone launches the app through a debugger, such as debugserver, the parent is the debugger instead. This makes getppid() != 1 one of the oldest and cheapest anti-debug checks in existence.

IDA shows us a stub named getppid at 0x969488:

0x969488: ADRP            X16, #qword_A40120@PAGE
0x96948C: LDR             X16, [X16,#qword_A40120@PAGEOFF]
0x969490: BR              X16

This looks like an ordinary import stub. But look at the slot it loads from. 0xA40120 is inside the first table we reversed in the previous section, which is the table of local code addresses. It’s index 4:

uint64_t code_offset = 0x5904E14C95A3221C - 5ULL * 0xAB676042843BC1B0;
// 0x7859AC

And at 0x7859AC:

0x7859AC: MOV             X16, #0x27
0x7859B0: STP             X17, X30, [SP,#var_10]!
0x7859B4: ADRL            X17, rawSyscallGate
0x7859BC: LDR             X17, [X17]
0x7859C0: BLR             X17
0x7859C4: LDP             X17, X30, [SP+0x10+var_10],#0x10
0x7859C8: B.CC            locret_7859D0
0x7859CC: B               sub_841CC0
0x7859D0: RET

Syscall 0x27 is 39, which is getppid. Darwin signals syscall failure by setting the carry flag, so B.CC returns on success and otherwise falls into an errno setter. So the “import” never touches libc at all. This is exactly what we predicted earlier: an import-looking callsite can lead right back into local code.

There are three callers. Two of them live inside runCoreSecurityDetections (0x563BB8), a 32KB flattened function, and the third lives inside a thread I’ll refer to as antiDebugWorkerThread (0x8C04C8).

The first callsite at 0x564F48 is strange:

0x564F48: BL              getppid
0x564F4C: MOV             W30, #0x629ABE52
0x564F54: MOV             W3, #0x3D74A225
0x564F5C: MOV             X8, X21
0x564F60: MOV             W9, #0x3FC7E2A9
0x564F68: B               loc_56B828
...
0x56B83C: LDR             X0, [X19,#0x2E0]   ; X0 overwritten

The result is thrown away. The dispatcher immediately reloads X0 from the stack without ever reading it. As far as I can tell, this call exists purely to make the cross-references noisier.

The second callsite at 0x56A3FC is the real check:

0x56A3FC: BL              getppid
0x56A400: MOV             W30, #0x629ABE52
0x56A408: MOV             W3, #0x3D74A225
0x56A410: CMP             W0, #1
0x56A414: CSET            W8, NE
0x56A418: STR             W8, [X19,#0x4C]

Which is simply:

bool debugged_by_parent = getppid() != 1;

The flag is stored at [X19,#0x4C] and later consumed at 0x568C8C, where it selects the next dispatcher state.

The anti-debug thread

The third caller is considerably more interesting. antiDebugWorkerThread is started from initAntiTamper through createProtectedThread at 0x8BBAE0. It detaches itself, and then loops forever, running a small family of anti-debug checks every five seconds.

Once again, the function is flattened, and once again, the constants that matter are hidden. For example, here’s the value written into the second slot of a sysctl MIB:

0x8C0E20: MOV             W8, #0xE94E9487
0x8C0E28: STUR            W8, [X29,#var_5C]
0x8C0E2C: LDUR            W8, [X29,#var_5C]
0x8C0E30: MOV             W9, #0x7AC3E108
0x8C0E38: BIC             W9, W9, W8
0x8C0E3C: MOV             W10, #0x853C1EF7
0x8C0E44: AND             W8, W8, W10
0x8C0E48: ORR             W8, W9, W8
0x8C0E4C: MOV             W9, #0x938D7581
0x8C0E54: EOR             W8, W8, W9
0x8C0E58: STR             W8, [X19,#0x2C]

That is:

x = 0xE94E9487;
mib[1] = ((0x7AC3E108 & ~x) | (x & 0x853C1EF7)) ^ 0x938D7581;
//     = 0xE

0xE is 14, or KERN_PROC. These mixed boolean-arithmetic expressions are scattered all over Shield, and they all collapse to a small constant once you evaluate them. After doing this for every constant in the thread, along with resolving the indirect calls and table entries, the loop reduces to:

void *antiDebugWorkerThread(void *arg)
{
    pthread_detach(pthread_self());

    for (;;) {
        sleep(5);

        // XXH64 of __text, more on this later
        if (xxh64_text(0xA3FFB8) != expected)
            exit(1);

        // CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid() (raw syscall 20)
        int mib[4] = { 1, 14, 1, getpid() };
        struct kinfo_proc info;
        size_t len = sizeof(info);   // 0x288
        if (sysctl(mib, 4, &info, &len, NULL, 0) != -1 &&
            (info.kp_proc.p_flag & P_TRACED))   // 0x800
            die();

        if (getppid() != 1)
            die();

        if (trapFlag != 147) {
            int type = 0;
            // raw syscall 54, FIODTYPE = 0x4004667A
            if (ioctl(STDERR_FILENO, FIODTYPE, &type) != -1 && type == D_TTY)
                die();
        }
    }
}

P_TRACED is the classic one. The kernel sets this flag in p_flag when a process is being traced, and it can be read by anyone through sysctl.

The TTY check is a little more subtle. When you tap an app on the home screen, stderr is not a terminal. When you launch it from lldb, or from a shell over SSH on a jailbroken device, it is. FIODTYPE asks the kernel what type of device a file descriptor refers to, and D_TTY (3) means a terminal.

Notice trapFlag, which lives at 0xA3DA18. It holds 114, it is compared against 147, and I could not find a single instruction that ever writes to it. My best guess is that it’s a build-time switch for disabling the TTY check, and that in this build of Clash Royale it is simply left on.

die() is an indirect call, resolved using the same generated-offset trick from earlier, to 0x94F820. That function performs a raw exit(1) syscall, and in case that somehow returns, calls libc’s exit(1) too.

Rolling Checksums

Shield checksums its own __text section five separate times, from five different places.

Each checksum is driven by a 40-byte descriptor in __data:

Descriptor Consumer
0xA39A40 objcCallbackTextChecksumGuard (0x1BCD28)
0xA39A88 masterGuardStateAggregator (0x1D1AFC)
0xA3B688 runPackageCheckWithCodeIntegrityGuard (0x3F9184)
0xA3C050 timedProvenanceTextChecksumGuard (0x448DD8)
0xA42D80 loadedImageScanner (0x91C5E0), inlined four times

Every descriptor is made of five encoded qwords. Let’s look at how the first one is decoded:

0x1BCFC8: ADRL            X14, textQwordSumDescriptor_A39A40
0x1BCFD0: LDP             X8, X11, [X14]          ; q0, q1
0x1BCFD4: LDP             X9, X12, [X14,#0x18]    ; q3, q4
0x1BCFD8: LDR             X13, [X14,#0x10]        ; q2
0x1BCFDC: MOV             W10, #0x4B0292B0
0x1BCFE4: SUB             X9, X9, X10
0x1BCFE8: MSUB            X9, X13, X12, X9
0x1BCFEC: ADD             X9, X9, X10
0x1BCFF0: MSUB            X10, X12, X8, X11
0x1BCFF4: SUB             X14, X10, X14
0x1BCFF8: NEG             X10, X14
0x1BCFFC: MOV             W15, #0x73044528
0x1BD004: ADD             X13, X13, X15
0x1BD008: MSUB            X11, X12, X11, X13
0x1BD00C: SUB             X11, X11, X15
0x1BD010: MOV             W12, #0x6082F8EA
0x1BD018: SUB             X11, X11, X12
0x1BD01C: SUB             X11, X11, X14
0x1BD028: ADD             X11, X11, X12
0x1BD02C: SUB             X11, X11, #8

More algebra! Let D be the runtime address of the descriptor itself, which comes from the PC-relative ADRL. Just like before, the immediate constants added and then subtracted contribute nothing, so we can throw them away:

expected = q3 - q2 * q4
start    = D - (q1 - q4 * q0)
last     = (q2 - q4 * q1) + start - 8

Notice that the descriptor’s own address is part of the encoding. Since D is a runtime address, start and last come out already slid. Nothing needs to ask dyld where scrollx was loaded.

The decompiled loop is then pleasantly short:

uint64_t sum = q0;
for (uint64_t *p = last; p >= start; p--)
    sum += *p;

if (sum != expected)
    // fail

The accumulator starts at q0. That’s the key to the whole scheme. Decoding all five descriptors gives:

Descriptor Range Seed (q0) Expected
0xA39A40 [0x7C80, 0x968E60) 0xDCCF046A29889FA0 0x12006D9867511DA0
0xA39A88 [0x7C80, 0x968E60) 0x4ADC2461BFC9A616 0x800D8D8FFD922416
0xA3B688 [0x7C80, 0x968E60) 0x9320A527A9C8EC87 0xC8520E55E7916A87
0xA3C050 [0x7C80, 0x968E60) 0xDBDE08EFE3D4C73C 0x110F721E219D453C
0xA42D80 [0x7C80, 0x968E60) 0xAEE8F2F53F6ABEAF 0xE41A5C237D333CAF

Every guard covers the exact same range, which is the entirety of __text in 8-byte words. The plain 64-bit sum of that range is 0x3531692E3DC87E00, and adding it to each seed reproduces every expected value exactly. Because each guard has its own random seed, all five expected constants look completely unrelated, even though they are really one checksum in five disguises.

This works on a live process because ARM64 __text contains no rebased pointers. Code is position independent, so the bytes in memory are identical to the bytes on disk regardless of where the image is loaded.

There is a funny detail here. __text ends at 0x968E64, but these guards stop at 0x968E60, since the last qword would read past the end. The final four bytes, a single BL sub_83700, are not covered by any of the five additive checksums.

There’s also a weakness that’s worth pointing out. A sum is linear. If you patch an 8-byte word and change its value by d, you can subtract d from some other unused 8-byte word in __text and all five checksums still pass, because all five sum over the same range. Promon seems to have known this, because these are not the only checksums over __text.

How each guard reacts to a mismatch is also interesting. In objcCallbackTextChecksumGuard, which runs inside an Objective-C notification callback that reads [[note object] isCaptured] (screen recording detection), the logic is:

if (sum == expected || pthread_create(&t, NULL, suicide_checksum_mismatch, NULL) == 0) {
    // ... continue handling the callback as normal
} else {
    exit(1);
}

On a mismatch, Shield lets the check return quietly. It spawns a separate thread whose job is to take the process down, and the callback continues normally. By the time the process dies, the stack that would have pointed you back at the check is long gone. Only when the thread can’t be created does it fall back to a plain exit(1).

XXH64 __text hash

At 0x121698 sits a function that is very out of place in this binary:

uint64_t sub_121698(const uint64_t *p, uint64_t len, uint64_t seed)
{
    if (len < 32) {
        h = seed + 0x27D4EB2F165667C5;
    } else {
        v1 = seed + 0x60EA27EEADC0B5D6;
        v2 = seed - 0x3D4D51C2D82B14B1;
        v3 = seed;
        v4 = seed + 0x61C8864E7A143579;
        do {
            v1 = 0x9E3779B185EBCA87 * __ROR8__(v1 - 0x3D4D51C2D82B14B1 * p[0], 33);
            v2 = 0x9E3779B185EBCA87 * __ROR8__(v2 - 0x3D4D51C2D82B14B1 * p[1], 33);
            v3 = 0x9E3779B185EBCA87 * __ROR8__(v3 - 0x3D4D51C2D82B14B1 * p[2], 33);
            v4 = 0x9E3779B185EBCA87 * __ROR8__(v4 - 0x3D4D51C2D82B14B1 * p[3], 33);
            p += 4;
        } while (p <= end - 32);
        ...

This one is wide open, with no flattening in sight, and those constants are instantly recognizable. 0x9E3779B185EBCA87 is PRIME64_1 from xxHash, 0x27D4EB2F165667C5 is PRIME64_5, and -0x3D4D51C2D82B14B1 is just PRIME64_2 written as a negative number. This is a textbook XXH64(input, length, seed).

The function itself is just a B to the real body at 0x12169C, and there’s a second thunk at 0x1234D8 that does the same. Neither has a single cross-reference.

By now, you probably know why. They’re called through generated offsets.

Let’s go back to antiDebugWorkerThread, and look at the hash check I glossed over earlier:

0x8C07B0: BL              materialize_ByteString   ; 8C BE 72 00 00 00 00 00
...
0x8C07D0: MOV             W10, #0x59DDB8DA
0x8C07D8: LDR             X9, [X19]                ; off_A3FFF0
0x8C07DC: ADD             X9, X9, X10
0x8C07E0: SUB             X8, X9, X8
0x8C07E4: SUB             X8, X8, X10              ; X8 = descriptor
0x8C07E8: LDP             X2, X9, [X8]             ; q0, q1
0x8C07EC: LDP             X15, X10, [X8,#0x28]     ; q5, q6
0x8C07F0: NEG             X11, X9
0x8C07F4: MADD            X11, X10, X2, X11
0x8C07F8: LDP             X12, X13, [X8,#0x10]     ; q2, q3
0x8C07FC: LDR             X14, [X8,#0x20]          ; q4
...
0x8C0858: ADD             X0, X8, X9
0x8C085C: BLR             X11
0x8C0860: STR             X0, [X19]
...
0x8C086C: LDR             X8, [X19]
0x8C0870: CMP             X25, X8

The first part is our old friend. off_A3FFF0 holds 0x116BE44, and the generated eight bytes are 0x72BE8C:

0x116BE44 - 0x72BE8C = 0xA3FFB8

So X8 is a pointer to another descriptor, this time made of seven qwords. Cancelling the constants once again gives us, with K = q6:

base     = D + K * q0 - q1      // image base
func     = base + q5 - K * q4   // what BLR X11 calls
data     = base + q2 - K * q1   // X0
len      = q3 - K * q2          // X1
seed     = q0                   // X2
expected = q4 - K * q3          // X25

Notice that q0 pulls double duty; it’s both the XXH64 seed and a term in the multiplication that hides the image base.

There are five of these descriptors, each consumed from a different place:

Descriptor Consumer Seed Expected
0xA3FF28 sub_8B3DA0 (0x8B4738) 0x8E2F525D1D64B459 0x17821C7BA5C87584
0xA3FF78 runSecondarySecurityDetections (0x8B5D50) 0x19B13087354FEB0A 0x30490102C4801485
0xA3FFB8 antiDebugWorkerThread (0x8C085C) 0x925693ADD4AD98F7 0xD6DCC246CA9EF693
0xA3FFF8 initAntiTamper (0x8BCF24, 0x8BD2DC) 0xE0348834518CF4C3 0xA91A7C9A528D881D
0xA40048 sub_8BD710 (0x8BDB90) 0x11DE558329731A9B 0x9486486F6692DE73

All five decode to the same function, 0x1234D8, the same data pointer, 0x7C80, and the same length, 0x9611E4. To make sure I wasn’t fooling myself, I hashed [0x7C80, 0x968E64) in Python with each seed, and all five match.

Two things stand out here.

First, the length is 0x9611E4, not 0x9611E0. XXH64 handles a trailing partial word, so these hashes cover the final four bytes that the additive checksums missed.

Second, XXH64 is nonlinear, so the “move the difference somewhere else” trick that beats the additive sums falls flat against it. And since XXH64’s own body lives at 0x12169C, inside the range it hashes, patching the hash function to return a canned value also changes the bytes being hashed, and every additive checksum along with it.

The additive sums and the XXH64 hashes protect each other.

Resource Checks

So far, everything Shield has checked has been inside its own process. The resource checks look outside of it, at the app bundle on disk.

The core of this is validateResourcePath at 0x3FEB38:

bool validateResourcePath(ResourceSet *set, std::string *rel, bool required)
{
    std::string path = set->bundle + "/" + *rel;

    // raw syscall 33
    if (access(path.c_str(), ...) == -1)
        return !required;

    return validateExistingResourceSignature(set, path);
}

sub_9690E0 looked like an import, but it’s index 9 in the local code table, and the code it points at is MOV X16, #0x21, or raw syscall 33, which is access. A missing file only fails the check if it was required. A file that is present is opened as a Mach-O, and every slice is passed to:

bool validateEmbeddedCodeSignature(MachOReader *r)
{
    return (cs = findLCCodeSignature(r))
        && translateCodeSignatureOffset(r, &cs)
        && (cd = findSuperBlobEntry(r, cs, 0))             // CodeDirectory
        && validateCodeDirectoryMagic(r, cd)
        && rejectAdHocCodeDirectory(r, cd)
        && validateCodeDirectoryIdentityPolicy(r, cd)
        && verifyCodeDirectoryPageHashes(r, cs, cd)
        && (cms = findSuperBlobEntry(r, cs, 0x10000))      // CMS signature
        && validateCMSBlobWrapperMagic(r, cms)
        && verifyCMSPKCS7X509(r, cms, cd);
}

Shield is reimplementing a decent chunk of Apple’s code signature validation by hand. It finds LC_CODE_SIGNATURE, pulls the CodeDirectory out of the SuperBlob, and refuses to continue if CS_ADHOC is set:

// flags are big-endian, so CS_ADHOC (0x2) reads as 0x2000000
return (flags & 0x2000000) == 0;

Then it checks the CodeDirectory’s team identifier against a policy value, hashes every page of the binary and compares those hashes to the ones in the CodeDirectory, and finally parses the CMS blob and verifies the PKCS#7 signature and its X.509 chain.

This is aimed squarely at re-signed apps. Sideloading tools re-sign every binary in the bundle with a different certificate (or no real certificate at all), and any one of those steps will fail.

What gets checked?

validateResourcePath is called four times from validateGuardResourceSet (0x3F9A68), which walks a set built once by buildGuardResourceSet (0x3FACE0). The interesting question is where that set comes from.

First, Shield needs to know where the bundle is. sub_4EBC3C takes the executable path, strips the last path component, strips a trailing /macOS if one exists, resolves it with realpath, and caches the result in qword_A5D048. The /macOS part is there for iOS apps running on Apple Silicon Macs, where the bundle layout is different.

Next, sub_1B4010 formats a path using a constructed string:

// '%s/%s.txt'
path = format("%s/%s.txt", bundle, "ii");

ii.txt. Let’s go take a look at it in the IPA:

00000000: bbba 1484 0b3e 1655 7fe3 2db8 4311 2829  .....>.U..-.C.()
00000010: db53 62de 4e6e a815 19f6 72f6 fc39 469e  .Sb.Nn....r..9F.
00000020: 502f fd44 b284 aecb 918f c707 46f3 f52a  P/.D........F..*

Pure line noise.

sub_1B4094 reads the file, then calls a function I named salsa20XorBlocks, with a 32-byte key and an 8-byte nonce. Of course, both of these are constructed at runtime using the same byte generation machinery from earlier. After decryption, Shield verifies that the last byte is a newline before accepting the result. Decrypting the 3046 byte file with Salsa20/20 reveals:

0;Clash_Royale
1;Frameworks/scrollx.framework/scrollx
2;Frameworks/ZendeskSDKHTTPClient.framework/ZendeskSDKHTTPClient
2;Frameworks/Usercentrics.framework/Usercentrics
2;Frameworks/RecaptchaInterop.framework/RecaptchaInterop
...
2;Frameworks/Flutter.framework/Flutter
...
2;Frameworks/libswift_Concurrency.dylib

An encrypted manifest of the bundle’s executables! parseGuardManifest (0x3FB0B8) splits it on \n and ; with strtok_r, and the leading number decides where each path goes:

Type Meaning Count Required?
0 Main executable 1 Yes
1 scrollx itself 1 Yes
2 Bundled frameworks and dylibs 53 No
3 56-byte entries, checked by sub_3FEEC4 0 -

Wait. sub_3FEEC4? That’s the function we used as our flattening example way back in the obfuscation section! It builds the same bundle + "/" + path string, calls the same access stub, and compares the result against fields of the entry. This manifest doesn’t contain any type 3 entries, so in Clash Royale that path is never used.

So, for every executable in the bundle: the main binary and scrollx must exist and must be properly signed; the other 53 frameworks may be missing, but if they exist, they must be properly signed.

Five Checks

Shield organizes many of its detections as C++ objects. Three of the five functions below aren’t called directly by anything; they are virtual methods referenced only by vtables at 0xA17290, 0xA19698, and 0xA166E0.

detectThreadEntrypointOutsideLoadedImages

This one, at 0x4070A8, asks a simple question: did every thread in this process start in code that belongs to a loaded image?

The problem is that pthread_t is opaque. There’s no public API to ask a thread for its start routine. Shield solves this in an amusing way, with discoverPthreadStartRoutineOffset (0x406854):

pthread_create(&thread, NULL, pthreadStartRoutineCalibrationWorker, &arg);

for (int off = 0; off < 0x2000; off += 8) {
    if (*(void **)((char *)thread + off) == pthreadStartRoutineCalibrationWorker)
        return off;
}

It creates a thread with a start routine it already knows, then scans the thread’s pthread_t structure in 8-byte steps until it finds that pointer. The offset is cached in the detector object, so this only happens once.

From there, the check is:

task_threads(mach_task_self(), &threads, &count);

for (i = 0; i < count; i++) {
    pthread_t pt = pthread_from_mach_thread_np(threads[i]);
    void *start = read_qword((char *)pt + start_routine_offset);

    Dl_info info;
    if (start && dladdr(start, &info) == 0)
        return DETECTED;
}

If dladdr fails, then the thread started executing somewhere that dyld knows nothing about, such as a manually mapped payload or some shellcode in an anonymous allocation.

HookIntegrityDetector

HookIntegrityDetector_detect at 0x4F8ADC looks at a list of 38 functions that sub_4F7078 registers through resolveAndRegisterSymbol. The list reads like a checklist of things you would want to hook if you were attacking an app:

  • Anti-debug and environment primitives: ptrace, sysctl, fork, stat, lstat, fstat, access, open, fopen, popen, system, realpath, readlink, task_threads, vm_region_64
  • dyld: dlopen, dlsym, dladdr, _dyld_image_count, _dyld_get_image_name, _dyld_get_image_header, _dyld_get_image_vmaddr_slide, _dyld_register_func_for_add_image
  • TLS and trust evaluation: SSL_CTX_set_custom_verify and SSL_set_custom_verify from libboringssl.dylib, nw_tls_create_peer_trust from libnetwork.dylib, SecTrustEvaluate and all of its async variants, SecTrustSetPolicies, SecKeyVerifySignature, SecKeyRawVerify, SSLHandshake, SSLCreateContext, SSLSetSessionOption
  • SecIsInternalRelease

Each function is then checked in up to three ways, with three separate lists inside the detector:

// 1. Global lookup must land inside the expected image
void *p = dlsym(RTLD_DEFAULT, name);
if (p && !inExpectedImage(p))
    return DETECTED;

// 2. First instruction must be unchanged
if (!entry.flag && *(uint32_t *)entry.address != entry.saved_instruction)
    return DETECTED;

// 3. Pointer slot must still point into the expected image
if (!isAddressInExpectedRegisteredImage(*entry.slot))
    return DETECTED;

These map cleanly onto the three common ways of hooking a C function on iOS. The first catches symbol interposition, where a different image wins the global lookup. The second catches inline hooks, like MSHookFunction or ElleKit, which overwrite the start of the target with a branch. The third catches pointer rebinding, like fishhook, which leaves the code alone and swaps out the import pointer instead.

detectObjCRuntimeHooks

detectObjCRuntimeHooks at 0x3F61A0 does the same thing for Objective-C. The methods it watches are built in sub_3F430C:

  • -[__NSCFLocalSessionTask _onqueue_didReceiveChallenge:request:withCompletion:]
  • -[__NSCFTCPIOStreamTask _onqueue_sendSessionChallenge:completionHandler:]
  • +[NSData dataFromSecureAppROMItem:]

The first two are the private methods that CFNetwork uses to hand TLS authentication challenges back to an app. In other words, exactly where you would sit if you wanted to bypass certificate pinning. The third is homegrown, a category method that Shield adds to NSData itself, at 0x6FA480. Each entry records which image the method should come from, such as /System/Library/Frameworks/CFNetwork.framework/CFNetwork, and validateObjCMethodOwnerImagePath compares that to reality:

Method m = class_getInstanceMethod(cls, sel);   // or class_getClassMethod
IMP imp = method_getImplementation(m);

Dl_info info;
if (dladdr(imp, &info) == 0)
    return DETECTED;                            // not in any image

return strcmp(info.dli_fname, expected_path) != 0;

A swizzled method’s IMP points into whatever tweak replaced it, not CFNetwork, and that’s enough.

The detector also protects Shield’s own callbacks. sub_3F38A8 asks PRMShieldEventManager and ShieldCallbackManager for their sharedManager, walks their observers, and for every callback object it finds, records its implementation’s address and its first instruction word. Later, it compares them:

if (!entry.flag && *(uint32_t *)entry.imp != entry.saved_instruction)
    return DETECTED;

This is the same instruction-word comparison we just saw in HookIntegrityDetector, applied to the code that receives Shield’s events. If you can’t patch the detections, patching the code that reacts to the detections is the next logical step, and Shield accounts for that too.

integrityManifestMismatchDetected

integrityManifestMismatchDetected at 0x59C1CC is reached through IntegrityManifestDetector’s verify method at 0x725684:

bool IntegrityManifestDetector::verify(...)
{
    if (!this->armed)
        return true;
    return integrityManifestMismatchDetected(&this->verifier, ...);
}

Its constructor at 0x724228 builds a SystemImageVerifier around three system libraries:

  • /usr/lib/system/libsystem_kernel.dylib
  • /usr/lib/system/libsystem_c.dylib
  • /usr/lib/system/libdyld.dylib

These are the libraries that hold the actual syscall wrappers, libc, and dyld’s API, which are the most valuable targets on the system for someone who wants to lie to an app.

Building the verifier involves walking each image’s load commands to find __TEXT,__text, and some of this is done by reading the file from disk directly, through open and raw pread (syscall 153) stubs from the local code table. The detector only arms itself if this construction succeeds.

The verification itself walks a list of 40-byte entries:

struct ManifestEntry {
    const void *data;
    const void *expected;
    uint64_t    data_len;
    uint64_t    digest_len;
    uint8_t     hash_type;
};

For every entry, it digests data and compares it against expected:

bool digestEntry(ManifestEntry *e, uint8_t *out)
{
    if (e->hash_type == 1 && e->digest_len == 20)
        return sha1(e->data, e->data_len, out) == OK;
    if (e->hash_type == 2 && e->digest_len == 32)
        return sha256(e->data, e->data_len, out) == OK;
    return false;
}

...

if (memcmp(out, e->expected, e->digest_len) != 0)
    // mismatch

Hash type 1 with 20 bytes and hash type 2 with 32 bytes are exactly CS_HASHTYPE_SHA1 and CS_HASHTYPE_SHA256 from Apple’s code signing format. The same two digest functions are also called from verifyCodeDirectoryPageHashes, which we just saw in the resource checks. Even memcmp here is homemade, a local byte loop at 0x6C5364 reached through the first table.

dyldImageScannerFamilyEntry

Finally, dyldImageScannerFamilyEntry at 0x91BBBC. Unlike the others, this one is called directly, from masterGuardStateAggregator, runSecondarySecurityDetections, and from runCoreSecurityDetections at 0x56B858, a few dispatcher states away from the getppid check we started with.

This is the check that I expected to see back when we were looking at Shield’s imports. The pattern lists are built by fillLoadedImagePatternTables (0x91E0AC). There are 14 image name patterns:

MobileSubstrate     CydiaSubstrate      SubstrateLoader
SubstrateBootstrap  libsubstitute       TweakInject
xCon                Liberty.dylib       FridaAgent
SSLKillSwitch       libellekit          systemhook
libroothide         roothideinit

And 12 symbol names:

MSFindSymbol        MSFindAddress       MSHookFunction
MSHookMessageEx     MSHookClassPair     SubGetImageByName
SubFindSymbol       SubHookFunction     SubHookMessageEx
_Z35replaced_IsAppCheckerPolicyViolatedv
_Z34replaced_devReadPermissionModifiedP8NSString
_Z34replaced_checkRootPermissionAtPathPKc

The first nine symbols are the public hooking APIs of Cydia Substrate and Substitute. The last three are more fun. Demangled, they are replaced_IsAppCheckerPolicyViolated(), replaced_devReadPermissionModified(NSString*), and replaced_checkRootPermissionAtPath(char const*). These are the replacement functions of some bypass, which left its symbols exported, and Promon simply added them to the list.

The scan runs in three phases:

// 1. Does any known hooking symbol resolve?
for (name in symbol_patterns)
    if (dlsym(RTLD_DEFAULT, name))
        detected = true;

// 2. Does any loaded image's path contain a known pattern?
for (i = 0; i < _dyld_image_count(); i++)
    for (pattern in image_patterns)
        if (strstr(_dyld_get_image_name(i), pattern))
            detected = true;

// 3. Same matcher, fed by a second image enumerator (sub_4EC284)

Remember where _dyld_image_count and _dyld_get_image_name come from? They’re two of the imports that weren’t in the import table, which Shield resolved itself by walking export tries in the third table. Phase two lives in loadedImageScanner (0x91C5E0), which, as we saw earlier, also carries four inlined copies of the 0xA42D80 rolling checksum.

The result is reported through sub_1D9FF4(qword_A59F90, 4, ...), the same reporting function runPackageCheckWithCodeIntegrityGuard calls with category 2.

Back to __ostekake

Way back at the start, I promised we would return to __ostekake, the tiny 16-byte segment with no cross-references. Ostekake is Norwegian for cheesecake.

Here it is again:

; AREA __ostekake, DATA, ALIGN=3
01 08 01 02 03 00 00 01
00 00 00 00 00 00 00 00
; __ostekake    ends

Earlier, I guessed this might be a configuration blob. Now that we know how Shield builds its strings, it’s worth asking a different question. If nothing references this segment by address, does anything reference it by name?

It does. materialize_String_ostekake (0x8035B0) builds the string "__ostekake" at runtime, using the same machinery from earlier, and its only caller is a function I named findOstekakeDescriptorInImage (0x4EB860).

Locating the descriptor

findOstekakeDescriptorInImage takes a Mach-O header and walks its load commands by hand:

uint64_t findOstekakeDescriptorInImage(mach_header_64 *header)
{
    uint64_t lowest_vmaddr = -1;
    uint64_t section_addr = 0;

    for (each load command lc) {
        if (lc->cmd != LC_SEGMENT_64)   // 25
            continue;

        segment_command_64 *seg = (segment_command_64 *)lc;
        if (seg->filesize != 0 && seg->vmaddr < lowest_vmaddr)
            lowest_vmaddr = seg->vmaddr;

        if (!section_addr && strcmp(seg->segname, "__DATA") == 0) {
            for (each section_64 sect in seg) {
                if (strcmp(sect->sectname, "__ostekake") == 0) {
                    section_addr = sect->addr;
                    break;
                }
            }
        }
    }

    if (!section_addr)
        return 0;

    // slide the section's address to where it actually lives
    return section_addr + (uint64_t)header - lowest_vmaddr;
}

Both "__DATA" and "__ostekake" are constructed strings, and both comparisons go through a local strcmp. Like everything else in Shield, there’s no getsectiondata and no dyld API involved.

On top of that sits getValidOstekakeDescriptor (0x4EBA40), which adds exactly one rule:

bool getValidOstekakeDescriptor(mach_header_64 *header, ..., uint8_t **out)
{
    uint8_t *desc = findOstekakeDescriptorInImage(header);
    if (desc && desc[7] != 0) {
        *out = desc;
        return true;
    }
    return false;
}

The eighth byte, the 01 at the end of the first row, marks the descriptor as valid. None of the other bytes in the first row are read anywhere in scrollx. They do line up with something, though. The main executable tags every Sentry crash report with the Shield version it shipped with:

// main executable, sub_100FDEB24
sentry_set_tag("promon.version", "8.1.2");

And the descriptor’s second, third, and fourth bytes are 08 01 02. My guess is that the first row is a small header: a layout version (01), the Shield version (8.1.2), what looks like a build or patch number (03), two bytes of padding, and the valid flag. The only part Shield actually enforces in this build is the valid flag.

Enumerating images without dyld

getOstekakeImage (0x4EBAF0) passes getValidOstekakeDescriptor as a predicate to findRegisteredImage (0x4EA408), and caches the first image that matches in qword_A5D038.

The interesting part is where findRegisteredImage gets its list of images. It never asks dyld. The list is built once, by sub_4EA050, which walks the entire address space of the task:

void buildImageRegistry(void)
{
    mach_vm_address_t addr = 0;
    natural_t depth = 0;

    pthread_mutex_lock(&registryLock);
    registry.clear();

    for (;;) {
        mach_vm_size_t size;
        vm_region_submap_info_data_64_t info;
        mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;   // 19

        if (mach_vm_region_recurse(mach_task_self(), &addr, &size,
                                   &depth, &info, &count) != KERN_SUCCESS)
            break;

        if (info.is_submap) {
            depth++;
            continue;
        }

        registry.push_back({ addr, info.protection });
        addr += size;
    }

    pthread_mutex_unlock(&registryLock);
}

Every region becomes a candidate, along with its protection. When a predicate wants to look at a candidate’s header, it never dereferences the region directly. sub_4EC790 allocates a buffer and copies the bytes out with a local wrapper around mach_vm_read_overwrite (sub_969E9C), so an unmapped or unreadable region fails the read instead of crashing the process. sub_4EBA94 then checks that the copy starts with 0xFEEDFACF, the 64-bit Mach-O magic.

This is the same “find it yourself” approach we saw with Shield’s import resolution. The list of images Shield works with comes from the kernel’s view of the address space, so hiding an image from dyld’s image list does nothing to hide it from this scan.

Both sides of the __ostekake handshake use this registry, with different predicates:

Function Predicate Finds
getExpectedRegisteredImage (0x4EA780) Region is r-x, header is 0xFEEDFACF, and the image’s segments contain the address of the predicate itself The image Shield is running from
getOstekakeImage (0x4EBAF0) Header is 0xFEEDFACF, and the image has a valid __ostekake descriptor Any image that publishes a descriptor

The first predicate is a neat trick. sub_4EA874 passes its own address to isAddressInsideMachOSegments, so the image it selects is, by definition, the one containing the code doing the asking.

Publishing the interface

So who writes to the descriptor? The second row of __ostekake, the 8 bytes at offset 8, is zero on disk, and that’s where the interesting part happens.

Remember bootstrapShieldRuntime, the first thing +[PRMShieldEventManager load] calls? At 0x8BD6B4, it calls initializeOstekake, which uses a once-guard (isOstekakeInitialized) to run initializeOstekakeRuntimeInterface (0x845F2C) exactly one time:

void initializeOstekakeRuntimeInterface(void)
{
    void *image = getExpectedRegisteredImage();
    uint8_t *desc = findOstekakeDescriptorInImage(image);
    if (!desc)
        return;

    OstekakeInterface *iface = make_shared<OstekakeInterface>();
    iface->lock = make_shared<pthread_mutex_t>(PTHREAD_MUTEX_INITIALIZER);

    // functions Shield offers to the other side
    iface->packageCheck        = runPackageCheckWithCodeIntegrityGuard;
    iface->masterGuard         = masterGuardStateAggregator;
    iface->compromiseCode      = sub_1CD6D4;
    iface->developerModeStatus = getDeveloperModeStatus;

    // slots the other side should fill in
    iface->storeFlagSlot       = &off_A3EED8;
    iface->getFlagSlot         = &off_A3EEE0;
    iface->emulatorSlot        = &off_A3EEB0;
    iface->hookingSlot         = &off_A3EEB8;
    iface->injectionSlot       = &off_A3EEA8;
    iface->customMessageSlot   = &off_A3EEC0;
    iface->terminationSlot     = &off_A3EED0;

    iface->queueing = true;

    forceWrite(desc + 8, &g_ostekakeInterface);
}

The 850045863 stored next to the mutex, 0x32AAABA7, is _PTHREAD_MUTEX_SIG_init, the signature of a statically initialized pthread_mutex_t.

sub_1CD6D4 is a three-instruction getter that returns a global I named deviceCompromiseDetectionCode. Its only writer is masterGuardStateAggregator at 0x1D400C, and it’s the same value that reportAndScheduleTermination later formats into the %JAILBREAK% placeholder.

The write itself goes through forceWrite (0x6B43C8), which wraps writeMemoryWithTemporaryProtection (0x6B42B4):

bool writeMemoryWithTemporaryProtection(void *dst, const void *src, size_t len)
{
    region r = queryVmRegionContainingAddress(dst);

    while (r.size) {
        bool writable = (r.protection & 7) == (VM_PROT_READ | VM_PROT_WRITE);

        if (!writable)
            mach_vm_protect(mach_task_self(), r.start, r.size, false,
                            VM_PROT_READ | VM_PROT_WRITE);

        kern_return_t kr = mach_vm_write(mach_task_self(), dst, src, len);

        if (!writable)
            mach_vm_protect(mach_task_self(), r.start, r.size, false, r.protection);

        if (kr != KERN_SUCCESS)
            return false;
        if (r.start + r.size >= dst + len)
            return true;

        r = queryVmRegionContainingAddress(r.start + r.size);
    }
    return false;
}

__DATA is already writable, so in practice this is a plain mach_vm_write. If someone remapped __ostekake read-only to block the handshake, Shield would flip the region back to read/write, write the pointer, and restore the original protection.

So the empty second row of __ostekake gets a pointer to an interface object. That object has two halves: four functions that Shield’s detection core exports, and seven slots for callbacks that it expects someone else to provide.

This makes __ostekake a rendezvous point between two components. The byte at offset 7 marks the descriptor as valid, and the pointer at offset 8 is where the core publishes its interface for the other side to pick up.

Consuming the interface

Once the core has published an interface through the descriptor, something has to consume it. That something is registerOstekakeCallbackHandler at 0x8FCF3C.

That address might look familiar. Back when we used Unicorn to resolve the first dynamically constructed call in +[PRMShieldEventManager load], we computed:

target = 0x3D901DE0 - 0x3D004EA4
       = 0x008FCF3C

That’s this function. The very first indirect call we ever resolved in this binary was the consumer of the __ostekake interface. The full initialization sequence in +load (0x8FF7B8) is:

bootstrapShieldRuntime();             // ... initializeOstekake() publishes the interface
initCrashHandler();
registerOstekakeCallbackHandler();    // 0x8FCF3C, via generated offset
sub_8FE4D8();                         // 0x8FE4D8, via generated offset

registerOstekakeCallbackHandler does the mirror image of what we just saw:

void registerOstekakeCallbackHandler(void)
{
    uint8_t *desc = getOstekakeImage();   // scan every image for a valid descriptor
    if (!desc || !*(void **)(desc + 8))
        return;

    shared_ptr<OstekakeInterface> iface = **(shared_ptr<OstekakeInterface> **)(desc + 8);

    // take what the core offers
    ostekakeGetPackageCheck          = iface->packageCheck;
    ostekakeGetMasterguardFn         = iface->masterGuard;
    ostekakeGetUnknown40Fn           = iface->compromiseCode;
    ostekakeGetDeveloperModeStatusFn = iface->developerModeStatus;

    // give the core what it asked for
    *iface->storeFlagSlot     = storePromonFlagInKeychain;
    *iface->getFlagSlot       = getPromonFlagInKeychain;
    *iface->emulatorSlot      = emulatorDetectedCallback;
    *iface->hookingSlot       = hookingFrameworksDetectedCallback;
    *iface->injectionSlot     = libraryInjectionPrevented;
    *iface->customMessageSlot = dispatchCustomTypedMessage;
    *iface->terminationSlot   = reportAndScheduleTermination;

    pthread_mutex_lock(iface->lock);
    for (event in iface->queue)
        handle(event);          // dispatchOstekakeEvent or reportAndScheduleTermination
    iface->queue.clear();
    iface->queueing = false;
    g_ostekakeInterface.reset();
    pthread_mutex_unlock(iface->lock);

    forceWrite(desc + 8, 0);
}

Notice the last line. Once the handshake is complete, the pointer in __ostekake is wiped back to zero. If you dump the process after initialization, the pointer slot is zero again and the section looks exactly like it does on disk.

The four values the consumer copies out land in globals, each with a small wrapper (ostekakeGetMasterguard, ostekakeGetUnknown40, ostekakeGetDeveloperModeStatus). In this build of Clash Royale, I could not find anything that calls those wrappers, directly or through any of the decoded tables. The consumer side takes the core’s exports, but nothing in this build uses them.

The callbacks

The seven callbacks travel in the other direction. Five of them are event handlers we have already met: emulator, hooking frameworks, library injection, custom messages, and reportAndScheduleTermination. The other two are more interesting, because they give the detection core persistent storage.

storePromonFlagInKeychain (0x6ED144) and getPromonFlagInKeychain (0x6ED230) read and write a single keychain item. The service is PromonService and the account is derived from PromonKeyV2, both constructed strings. The value is a 5-byte blob built by sub_6ECB58:

std::vector<uint8_t> encodeFlag(bool flag)
{
    std::vector<uint8_t> blob;
    blob.push_back(0x02);
    for (int i = 0; i < 4; i++)
        blob.push_back(rand());

    bool stored = ((blob[3] >> 3) ^ (blob[2] >> 2)) & 1;
    if (stored != flag)
        blob[2] ^= 4;

    return blob;
}

The flag is a single bit, spread across two random bytes. Reading it back reverses the same expression:

bool getPromonFlagInKeychain(void)
{
    std::vector<uint8_t> blob = keychainRead("PromonService", account);
    if (blob.size() != 5)
        return false;
    return ((blob[3] >> 3) ^ (blob[2] >> 2)) & 1;
}

Every write produces a different blob, so you can’t recognize a “flagged” value by comparing bytes, and you can’t tell which bit matters without knowing the expression.

masterGuardStateAggregator stores byte_A5CD38 through this mechanism at 0x1D3060 and 0x1D3FB0, and reads it back at 0x1D33A0 to decide which dispatcher state to take next. A few states later, at 0x1D400C, it writes deviceCompromiseDetectionCode. On iOS, keychain items usually survive the app being deleted and reinstalled, which makes the keychain a good place to remember that a device has been flagged before.

The queue

Why the queue? Shield’s detections start running inside bootstrapShieldRuntime, before the callback handler has been registered. Something might be detected in that window, and there would be nobody to tell.

The default contents of the callback slots handle this. For example, the default emulator callback at 0x845CEC builds a 40-byte event record with type 1 and sends it to 0x84587C, and the default termination callback at 0x845AD8 does essentially the same:

void ostekakeSend(Event *e)
{
    initializeOstekake();

    pthread_mutex_lock(iface->lock);
    if (iface->queueing)
        iface->queue.push_back(*e);   // nobody is listening yet
    else
        deliver(e);                   // call through the slot
    pthread_mutex_unlock(iface->lock);
}

Until a handler is registered, events pile up in the queue. When registerOstekakeCallbackHandler runs, it drains the queue, flips queueing off, and from then on every event goes straight through the slots it just filled in.

Each queue record is 40 bytes, and a field at offset 0x20 says what kind of record it is. Records queued by the default termination callback carry a reason code and a copy of the URL template, and are replayed through reportAndScheduleTermination once the handler exists. Event records, tagged 1, are dispatched by dispatchOstekakeEvent (0x8FCC2C) according to their type:

Type Handler
0 libraryInjectionPrevented
1 emulatorDetectedCallback
2 hookingFrameworksDetectedCallback
3 dispatchCustomTypedMessage
anything else killpg(0, SIGKILL); exit(1);

Hand Shield an event type it doesn’t recognize and it takes down the entire process group.

Why?

In Clash Royale, both sides of this handshake live inside scrollx. I checked every Mach-O in the bundle, and scrollx is the only one with an __ostekake section. The main executable imports zero symbols from scrollx. Its only tie is an LC_LOAD_DYLIB command, and everything starts from +load.

So why go through all this trouble just to talk to yourself?

My best guess is that this is about how Shield is built and shipped. The detection core and the app-facing callback layer are separate components that agree on a tiny ABI: a versioned header, one valid byte, one pointer, and a shared interface object. Neither half needs to link against the other, or even live in the same image. The consumer accepts a descriptor from any image in the process, as long as some image publishes one, while the producer only ever publishes into the image it’s running from. The pieces that support this are all present:

  • The descriptor header appears to carry the Shield version, 8.1.2.
  • The two sides locate each other by scanning the address space, with no linking or exported symbols involved.
  • The interface is reference counted, protected by its own mutex, and queues events until the other side shows up, so load order between the two components doesn’t matter.
  • The consumer copies out four exports that nothing in this build uses.

Clash Royale ships both halves inside the same framework, so here all of that flexibility goes unused.

As a reverse engineer, it also means that the cleanest place to see every event that Shield’s detection core raises is the seven slots that pass through __ostekake. That saves you from combing through hundreds of flattened detection functions.

Getting Caught

Every detection we have looked at so far eventually has to do something about what it found. For most of them, that something is a function I named reportAndScheduleTermination at 0x6E63A0. It is also the function that registerOstekakeCallbackHandler installs into the termination slot of the __ostekake interface.

It has 32 call sites, spread across runCoreSecurityDetections, runSecondarySecurityDetections, fridaDetectionWorkerThread, hookingFrameworksDetected, createProtectedThread, and a handful of smaller functions. Every one of them looks roughly like this:

0x566BB4: LDR             X0, [X19,#0x158]        ; policy object
0x566BBC: BLR             X6                      ; resolved target=+0x52C714
0x566BC0: LDR             X1, [X0]                ; URL template
0x566BC4: CBNZ            X1, loc_566BD4
0x566BC8: ADRL            X1, qword_A2D400
0x566BD0: STRB            WZR, [X1]
0x566BD4: MOV             W0, #5                  ; reason code
0x566BD8: BL              reportAndScheduleTermination

The first argument is a reason code. The second is a URL template, which the caller fetches from a getter on Shield’s policy object. 0x52C714 is simply:

std::string *sub_52C714(Policy *policy)
{
    return (std::string *)((char *)policy + 1328);
}

So every reason has its own URL field in the policy.

Reason codes

Mapping each reason code to a detection is harder than it sounds, since most call sites are buried inside flattened dispatchers that reuse their states. These are the ones I could tie to a detection with confidence:

Reason Detection Evidence
0 Jailbreak buildSignedURL only fills in %JAILBREAK% for reason 0
2 Repackaging Reported by waitForRepackagingCheck, and when runPackageCheckWithCodeIntegrityGuard returns 0
3 Screenshot ScreenshotMonitor observes UIApplicationUserDidTakeScreenshotNotification, fires screenshotDetected, then reports 3
5 Hooking frameworks Reported by hookingFrameworksDetected, createProtectedThread, and the Objective-C method owner checks
6 Screen recording Reported from the isCaptured path of objcCallbackTextChecksumGuard, alongside screenRecordStatusChanged:
8 Frida or emulator Reported by fridaDetectionWorkerThread, which also fires emulatorDetected

Codes 1, 4, 7, 9, and 16 are only reported from inside runCoreSecurityDetections and runSecondarySecurityDetections, and I wasn’t able to pin them down. The callbacks that remain unaccounted for are debuggerStatus:, systemIntegrityProtectionStatus:, libraryInjectionDetected, and developerModeStatus:, so it’s a safe bet that most of them are in there somewhere.

The policy

The policy object comes from a file in the app bundle. sub_92390C reads config-encrypt.txt, with a 1MB cap, and runs it through a pipeline that looks like this:

config-encrypt.txt (15856 bytes)
  [ IV (16) | AES-CBC ciphertext | RSA-2048 signature ]

1. verify the trailing signature against an embedded public key
2. AES-CBC decrypt with the 16-byte IV, strip PKCS#7 padding
3. check the "AQaq" magic, read a u32 size
4. zlib inflate the body (a size of -1 means it's stored raw)
5. SecurityPolicyProvider_parseStream

The public key is a base64 PKCS#1 RSAPublicKey, constructed at runtime from several strings:

MIIBCgKCAQEA53xmGWHvon0QjcmuCuUDHs8YHiluIzHU0OHmFPhS1hl487EygaCp
03tg5M/BzP15ej79UPNyTdC1IcgTJ74aV+A+3M5zdqB41icWqHLs6n5fIOxG7IhO
1X0dzMfORLgR3OEup56klr6zhpzIdEq3Lip9oK+iblvG/wAZ0xlL4HzLMjV2a5RG
FP0UbaFJRq0Pp49njgAC86z9xeRdu6R+JB1SwCzt/QMbmT5lq97mhJu8vL/15s/o
ETGKu7H5K/EypU/B5sBMIK5SKzlKx+gy4+T8TtXeiGXN5O/ak7wMvgsUbO6+Fw6p
...

The AES key is a different story. The CBC block function at 0x1530BC performs AES-style table lookups into a 0x281F8-byte context, in place of a plain key schedule. That context is generated at runtime by sub_5A89EC, a single 314KB function of roughly 73,000 instructions, which decodes a long stream of constants and writes the tables out four bytes at a time. There is no key sitting in the binary to recover, so I did not decrypt the config statically.

What we can say is where the parsed values end up. Three of them matter for termination:

Offset Meaning
policy+80 Signing key for the report URL
policy+552, policy+1328, … Per-reason URL templates
policy+1216 Hard exit flag

Building the URL

If the URL template is empty, reportAndScheduleTermination skips straight to termination. Otherwise, it hands the template to buildSignedURL (0x6E5554), which replaces a set of placeholders:

std::string buildSignedURL(int reason, std::string tmpl)
{
    replace(tmpl, "%REASON%",       to_string(reason));
    replace(tmpl, "%MANUFACTURER%", "Apple");
    replace(tmpl, "%MODEL%",        model);
    replace(tmpl, "%VERSION%",      version);
    replace(tmpl, "%OSVER%",        os_version);
    replace(tmpl, "%IOS%",          ...);
    replace(tmpl, "%MACOS%",        ...);

    if (reason == 0)
        replace(tmpl, "%JAILBREAK%", upper(format(sub_1CD6D4() + 22)));

    if (reason == 256) {
        replace(tmpl, "%PKGID%",      ...);
        replace(tmpl, "%PKGVERSION%", ...);
        replace(tmpl, "%CREATED%",    ...);
    }

    std::vector<uint8_t> key = policy->signingKey();   // policy+80
    if (!key.empty()) {
        tmpl += contains(tmpl, '?') ? "&shrand=%RAND%&shts=%TS%"
                                    : "?shrand=%RAND%&shts=%TS%";
        replace(tmpl, "%RAND%", random_u32());
        replace(tmpl, "%TS%",   timestamp());

        uint8_t mac[32];
        keyed_hash(tmpl, key, mac);                    // sub_906740
        tmpl += "&shhash=" + hex(mac);                 // 64 hex characters
    }
    return tmpl;
}

sub_1CD6D4 should look familiar. It’s one of the four functions the detection core publishes through __ostekake, and it returns deviceCompromiseDetectionCode, the value masterGuardStateAggregator computes. So the report URL for a jailbreak carries the compromise code itself, which most likely encodes which jailbreak indicators fired.

Reason 256 gets the package placeholders, although none of the call sites I found actually pass 256.

The shrand, shts, and shhash parameters presumably let whoever receives the URL verify that it came from Shield, and that it wasn’t replayed or edited along the way.

Opening the URL

Next, Shield opens the URL. How it does that depends on the platform, which sub_4EACE4 reads from the main executable’s load commands (LC_VERSION_MIN_* or LC_BUILD_VERSION).

On macOS, it calls -[NSWorkspace openURL:]. If that succeeds, the completion block calls 0x94F820, which we saw back in the anti-debug thread: a raw exit(1) syscall, followed by libc’s exit(1).

On iOS, it takes a mutex at 0xA3E7B0 and checks two things: whether [UIApplication sharedApplication] exists yet, and whether it’s running on the main thread. If the app is already up, it waits until UIApplication exists and calls -[UIApplication openURL:options:completionHandler:].

The interesting case is a detection that fires on the main thread before the app has launched. Remember, Shield starts running from +load, long before main. In that window there is no UIApplication to open a URL with, so Shield creates one:

pthread_create(&t, NULL, openURLWhenReady, url);

Class app = objc_allocateClassPair(UIApplication, "PromonApplication", 0);
class_addMethod(app, @selector(_loadMainInterfaceFile), return_0, "i@:");
objc_registerClassPair(app);

Class delegate = objc_allocateClassPair(UIResponder, "PromonApplicationDelegate", 0);

if (Info.plist contains UIApplicationSceneManifest) {
    Class scene = objc_allocateClassPair(UIResponder, "Chae2AiPSceneDelegate", 0);
    class_addProtocol(scene, @protocol(UIWindowSceneDelegate));
    objc_registerClassPair(scene);
    class_addMethod(delegate,
        @selector(application:configurationForConnectingSceneSession:options:),
        sub_6E8EE0, "@@:@@@");
}

class_addMethod(delegate, @selector(application:didFinishLaunchingWithOptions:),
                return_1, "c@:@@");
class_addProtocol(delegate, @protocol(UIApplicationDelegate));
objc_registerClassPair(delegate);

UIApplicationMain(0, NULL, @"PromonApplication", @"PromonApplicationDelegate");

PromonApplication overrides _loadMainInterfaceFile to return 0, so no storyboard is ever loaded, and PromonApplicationDelegate simply returns YES from application:didFinishLaunchingWithOptions:. UIApplicationMain itself is located with Shield’s own export resolver against UIKit, with dlsym as a fallback.

The result is an empty application whose only purpose is to exist. Meanwhile, the thread started at the top waits until UIApplication is available, opens the URL, and exits through 0x94F820. If the first attempt fails, firstOpenFailed tries again. The game’s own UI never starts.

Terminating

On iOS, when the app is already running, both openURL completion blocks are empty. Termination is handled by the last thing reportAndScheduleTermination does:

0x6E6B1C: MOV             X0, #0
0x6E6B20: MOV             W1, #0x1DCD6500         ; 500,000,000 ns
0x6E6B28: BL              dispatch_time
...
0x6E6B40: ADR             X8, sub_6E6F10          ; block invoke
...
0x6E6B58: STR             W19, [SP,#0xE0+var_C0]  ; captured reason code
0x6E6B5C: ADRP            X1, #__dispatch_main_q_ptr@PAGE
0x6E6B60: LDR             X1, [X1,#__dispatch_main_q_ptr@PAGEOFF]
0x6E6B68: BL              dispatch_after

Half a second later, on the main queue, the block calls 0x6E620C with the reason code. IDA merges this function into the tail of buildSignedURL, so it’s easy to miss. It does two things:

void terminate(int reason)
{
    if (policy->hardExit)          // byte at policy+0x4C0 (1216)
        hard_exit();               // 0x94F820

    crashWithEncodedReason(reason);    // 0x742B64
}

If the hard exit flag in the policy is set, the process exits immediately. Otherwise, Shield crashes itself, on purpose, in a very specific way.

The crash

crashWithEncodedReason (0x742B64) starts by building a seed:

struct {
    uint64_t mono_ns;      // CLOCK_MONOTONIC_RAW
    uint64_t wall_us;      // wall clock, microseconds
    uint64_t mono_ns2;     // CLOCK_MONOTONIC_RAW again
    uint64_t prev_seed;    // qword_A5D4B8
} input;

uint8_t digest[32] = SHA256(&input, 64);
qword_A5D4B8 = digest[8..15];
uint64_t seed = digest[0..7];

It then fills an array of 28 register values:

uint64_t regs[28];
regs[0] = seed;
regs[1] = next(&seed, 1) + reason;
for (int i = 2; i < 28; i++)
    regs[i] = next(&seed, i);

next (0x742CBC) runs a 16-byte block, derived from i, through a keyed transform (sub_906CCC) with the current state as the key. Half of the output becomes the new state and the other half is returned. The reason code only appears in regs[1], offset by a value that can be recomputed from regs[0].

sub_742B3C then zeroes 8KB of the stack below the current frame, and sub_742D90 does the rest:

0x742D90: MOV             X8, #1
0x742D94: MOV             X9, X8
0x742D98: LSL             X8, X8, #4
0x742D9C: ADD             X9, X9, #1
0x742DA0: ADD             X8, X8, X9
0x742DA4: CMP             X9, #9
0x742DA8: B.LE            loc_742D98
0x742DAC: LSL             X8, X8, #8              ; X8 = 0x123456789A00
0x742DB0: MOV             X28, X0
0x742DB4: MOV             X29, #0
0x742DB8: MOV             X30, X8
0x742DBC: MOV             SP, X8
0x742DC0: LDP             X0, X1, [X28]
0x742DC4: LDP             X2, X3, [X28,#0x10]
...
0x742DF4: LDP             X26, X27, [X28,#0xD0]
0x742DF8: STR             X28, [X28,X29]          ; overwrite the array
0x742DFC: ADD             X29, X29, #8
0x742E00: CMP             X29, #0xD8
0x742E04: B.LE            loc_742DF8
0x742E08: MOV             X28, X30
0x742E0C: MOV             X29, X30
0x742E10: BR              X30

The loop at the top builds 0x123456789A one hex digit at a time, then shifts it left by 8. SP, LR, FP, and X28 are all set to 0x123456789A00, X0 through X27 are loaded from the array, the array is overwritten with its own address, and Shield branches to 0x123456789A00.

Nothing is mapped there, so the process dies with EXC_BAD_ACCESS. The crash report has an instantly recognizable program counter, and the registers carry the seed in X0 and the encoded reason in X1. Everything else that could explain the crash has already been wiped from the stack.

Putting it all together, a detection that reaches reportAndScheduleTermination on a running iOS app goes through these steps:

detection
  -> reportAndScheduleTermination(reason, policy->url[reason])
       -> empty URL? terminate now
       -> buildSignedURL: placeholders, shrand, shts, shhash
       -> openURL in the browser
       -> dispatch_after(0.5s)
            -> policy+1216 set?  exit(1)
            -> otherwise         crash at 0x123456789A00, reason in X1

Conclusion

The thread running through nearly everything in this post is that Shield only trusts what it derives itself. It finds loaded images by walking the task’s VM regions with mach_vm_region_recurse, and copies their headers out with mach_vm_read_overwrite before reading them. It resolves imports with its own export-trie walker, makes syscalls through its own SVC 0x80 stub, and compares strings and buffers with its own strcmp and memcmp. Even the two halves of Shield find each other this way, by scanning the address space for a section called __ostekake. The individual checks are mostly familiar ones: getppid, P_TRACED, a checksum over __text, a list of tweak names. The facts those checks run on come from the kernel and from Shield’s own parsing, so hooking dyld or libc to lie about them gets an attacker very little.

The layers also protect each other. Five additive checksums and five XXH64 hashes cover the same __text, the hash function lives inside the range it hashes, and each guard is seeded differently, so no two expected values match. HookIntegrityDetector and detectObjCRuntimeHooks record the first instruction of every function they watch, including the callbacks that receive Shield’s own events, so patching the code that reacts to a detection is itself detectable. Detections that fire before anything is listening are queued behind a mutex and replayed once the handler registers.

Getting caught is designed to be useful to Promon and useless to the attacker. If the policy provides a URL for the detection, the user is sent to it with the reason, device details, and an HMAC over the whole URL, and if that happens before the app has launched, Shield builds a throwaway UIApplication just to open it. Half a second later the process either exits, or crashes at 0x123456789A00 with the reason encoded in X1 and the stack wiped, which gives Promon an instantly recognizable crash report and gives the attacker nothing pointing back at the check. A keychain flag that usually survives reinstalling the app lets Shield remember a device it has flagged before.

There are weaknesses. __ostekake enforces a single byte, and the consumer copies out four exports that nothing in this build calls. The TTY check in the anti-debug thread is gated by trapFlag, a constant that is never written. All five additive checksums cover the same range, so a patch can be balanced by an adjustment elsewhere in __text, leaving XXH64 to do the real work. A reason whose URL template is empty skips the report entirely, and the signing key, URLs, and hard exit flag all live in the policy, so much of Shield’s response is set by configuration. The shared syscall stub keeps libc out of the picture, but it also gives every raw syscall in the binary the same cross-reference.

Some parts remain open. The policy in config-encrypt.txt is decrypted with AES tables generated at runtime by a 314KB function, and I have not recovered its contents, which would also settle reason codes 1, 4, 7, 9, and 16. fridaDetectionWorkerThread deserves its own look. In hindsight, the very first indirect call we resolved, the one in +[PRMShieldEventManager load] that computed 0x8FCF3C, was registerOstekakeCallbackHandler, the function that connects Shield’s detection core to everything that reacts to it. The answer to how the whole system fits together was sitting in the third call Shield ever makes.