Files
vphone-cli/research/txm_fullchain_analysis.md
Lakr 5388e0c9c5 Squash merge startup-hang-fix into main
Prefix research patch comparison doc and normalize root markdown names

Rename research root markdown files to scoped topic names
2026-03-06 02:42:12 +08:00

16 KiB

TXM Selector 24 — Full Chain Analysis

Overview

This document maps the complete TXM code signature validation chain for selector 24, from the top-level entry point down to the hash extraction functions. IDA base for this analysis: 0xfffffff017020000 (VA = raw_offset + 0xfffffff017004000).

Return Code Convention

TXM uses a multi-byte return code convention:

Byte Purpose
Byte 0 Check identity (e.g., 0xA1 = check_hash_flags)
Byte 1 Error indicator: 0x00 = pass, non-zero = fail
Byte 2+ Additional error info

The caller checks (result & 0xFF00) == 0 — if byte 1 is zero, the check passed.

Important: The previous txm_selector24_analysis.md had SUCCESS/ERROR labels swapped. Corrected mappings:

Return Value Byte 1 Meaning in Caller Description
0xA1 0x00 PASS Hash check passed
0x130A1 0x30 FAIL Hash consistency mismatch
0x22DA1 0x2D FAIL Hash flags type violation

The panic log selector: 24 | 0xA1 | 0x30 | 1 decodes to return code 0x130A1.


Full Call Chain

cs_evaluate (0xfffffff017024834)
  |
  +-- cs_blob_init (0xfffffff0170356D8)
  |     Parse CS blob, extract hash, setup entitlements
  |
  +-- cs_determine_selector (0xfffffff0170358C0)
  |     Determine selector byte (written to ctx+161)
  |     Also calls hash_flags_extract internally
  |
  +-- cs_selector24_validate (0xfffffff0170359E0)
        Sequential validation chain (8 checks + type switch):
        |
        |  Each check returns low-byte-only (0xNN) on PASS,
        |  multi-byte (0xNNNNN) on FAIL. Chain stops on first FAIL.
        |
        +-- [1] check_library_validation  (0x...5594)  ret 0xA9 on pass
        +-- [2] check_runtime_flag        (0x...552C)  ret 0xA8 on pass
        +-- [3] check_jit_entitlement     (0x...5460)  ret 0xA0 on pass
        +-- [4] check_hash_flags          (0x...5398)  ret 0xA1 on pass  <<<< PATCH TARGET
        +-- [5] check_team_id             (0x...52F4)  ret 0xA2 on pass
        +-- [6] check_srd_entitlement     (0x...5254)  ret 0xAA on pass
        +-- [7] check_extended_research   (0x...51D8)  ret 0xAB on pass
        +-- [8] check_hash_type           (0x...5144)  ret 0xAC on pass
        |
        +-- switch(selector_type):
              case 1: sub_...4FC4
              case 2: sub_...4E60
              case 3: sub_...4D60
              case 4: sub_...4CC4
              case 5: (no additional check)
              case 6-10: sub_...504C

Check #4: check_hash_flags (0xfffffff017035398)

This is the function containing both JB patch sites.

IDA Addresses → Raw File Offsets

IDA VA Raw Offset Content
0xfffffff017035398 0x31398 Function start (PACIBSP)
0xfffffff0170353B0 0x313B0 mov x19, x1 (after prologue)
0xfffffff0170353CC 0x313CC PATCH 1: ldr x1, [x20, #0x38]
0xfffffff0170353D4 0x313D4 PATCH 2: bl hash_flags_extract
0xfffffff017035418 0x31418 mov w0, #0x30A1; movk ...#1
0xfffffff017035420 0x31420 Exit path (LDP epilogue)
0xfffffff017035454 0x31454 mov w0, #0x2DA1; movk ...#2

Decompiled Pseudocode

// IDA: check_hash_flags  @ 0xfffffff017035398
// Raw: 0x31398, Size: 0xC8
//
// a1 = CS context pointer (x0)
// a2 = selector_type (x1, from ctx+161)
//
uint32_t check_hash_flags(cs_ctx **a1, uint32_t a2) {
    void *chain = **a1;                         // 0x353B8-0x353BC

    uint32_t flags = 0;                         // 0x353C4: str wzr, [sp, #4]
    uint64_t hash_data = 0;                     // 0x353C0: str xzr, [sp, #8]

    // Extract hash flags from CS blob
    hash_flags_extract(a1[6], a1[7], &flags);   // 0x353C8-0x353D4
    //                  ^^^^   ^^^^
    //               cs_blob  cs_size
    //   PATCH 1 @ 0x353CC: loads cs_size into x1
    //   PATCH 2 @ 0x353D4: calls hash_flags_extract

    // Extract hash data pointer from CS blob
    hash_data_extract(a1[6], a1[7], &hash_data); // 0x353D8-0x353E0

    // --- Decision logic ---

    // High-type shortcut: type >= 6 with chain data → pass
    if (a2 >= 6 && *(uint64_t*)(chain + 8) != 0)
        return 0xA1;  // PASS

    // Consistency check: hash data presence vs flag bit 1
    //   flag bit 1 = "hash data exempt" (when set, no hash data expected)
    bool has_data = (hash_data != 0);
    bool exempt   = (flags >> 1) & 1;

    if (has_data == exempt)
        return 0x130A1;  // FAIL — inconsistent (data exists but exempt, or vice versa)

    // Type-specific logic
    if (a2 > 5)
        return 0xA1;  // PASS — types 6+ always pass here

    if (a2 == 1 || a2 == 2) {
        if (exempt)
            return 0xA1;   // PASS — type 1-2 with exempt flag
        return 0x22DA1;    // FAIL — type 1-2 must have exempt flag
    }

    // Types 3-5
    if (!exempt)
        return 0xA1;       // PASS — type 3-5 without exempt
    return 0x22DA1;        // FAIL — type 3-5 must not be exempt
}

Assembly (Complete)

0x31398:  pacibsp
0x3139C:  sub   sp, sp, #0x40
0x313A0:  stp   x22, x21, [sp, #0x10]
0x313A4:  stp   x20, x19, [sp, #0x20]
0x313A8:  stp   x29, x30, [sp, #0x30]
0x313AC:  add   x29, sp, #0x30
0x313B0:  mov   x19, x1              ; x19 = selector_type
0x313B4:  mov   x20, x0              ; x20 = cs_ctx
0x313B8:  ldr   x8, [x0]             ; x8 = *ctx
0x313BC:  ldr   x21, [x8]            ; x21 = **ctx (chain)
0x313C0:  str   xzr, [sp, #8]        ; hash_data = 0
0x313C4:  str   wzr, [sp, #4]        ; flags = 0
0x313C8:  ldr   x0, [x0, #0x30]      ; x0 = ctx->cs_blob
0x313CC:  ldr   x1, [x20, #0x38]     ; x1 = ctx->cs_size      <<< PATCH 1
0x313D0:  add   x2, sp, #4           ; x2 = &flags
0x313D4:  bl    hash_flags_extract   ;                         <<< PATCH 2
0x313D8:  ldp   x0, x1, [x20, #0x30] ; reload blob + size
0x313DC:  add   x2, sp, #8           ; x2 = &hash_data
0x313E0:  bl    hash_data_extract    ;
0x313E4:  ldr   w8, [sp, #4]         ; w8 = flags
0x313E8:  cmp   w19, #6
0x313EC:  b.lo  0x31400
0x313F0:  ldr   x9, [x21, #8]
0x313F4:  cbz   x9, 0x31400
0x313F8:  mov   w0, #0xA1            ; PASS
0x313FC:  b     0x31420              ; → exit
0x31400:  and   w8, w8, #2           ; flags & 2
0x31404:  ldr   x9, [sp, #8]         ; hash_data
0x31408:  cmp   x9, #0
0x3140C:  cset  w9, ne               ; has_data = (hash_data != 0)
0x31410:  cmp   w9, w8, lsr #1       ; has_data vs exempt
0x31414:  b.ne  0x31434              ; mismatch → continue checks
0x31418:  mov   w0, #0x30A1          ; FAIL (0x130A1)
0x3141C:  movk  w0, #1, lsl #16
0x31420:  ldp   x29, x30, [sp, #0x30] ; EXIT
0x31424:  ldp   x20, x19, [sp, #0x20]
0x31428:  ldp   x22, x21, [sp, #0x10]
0x3142C:  add   sp, sp, #0x40
0x31430:  retab
0x31434:  cmp   w19, #5
0x31438:  b.hi  0x313F8              ; type > 5 → PASS
0x3143C:  sub   w9, w19, #1
0x31440:  cmp   w9, #1
0x31444:  b.hi  0x31450              ; type > 2 → check 3-5
0x31448:  cbnz  w8, 0x313F8          ; type 1-2 + exempt → PASS
0x3144C:  b     0x31454
0x31450:  cbz   w8, 0x313F8          ; type 3-5 + !exempt → PASS
0x31454:  mov   w0, #0x2DA1          ; FAIL (0x22DA1)
0x31458:  movk  w0, #2, lsl #16
0x3145C:  b     0x31420              ; → exit

Sub-function: hash_flags_extract (0xfffffff0170335D8)

Raw offset: 0x2F5D8, Size: 0x40

// Extracts 32-bit hash flags from CS blob at offset 0xC (big-endian)
uint32_t hash_flags_extract(uint8_t *blob, uint32_t size, uint32_t *out) {
    if (!out)
        return 0x31;  // no output pointer, noop

    if (blob + 44 <= blob + size) {
        // Blob is large enough
        *out = bswap32(*(uint32_t*)(blob + 12));  // flags at offset 0xC
        return 0x31;
    }

    // Blob too small — error
    return error_handler(25);
}

Sub-function: hash_data_extract (0xfffffff0170336F8)

Raw offset: 0x2F6F8, Size: 0xA8

// Extracts hash data pointer from CS blob
// Reads offset at blob+48 (big-endian), validates bounds, finds null-terminated data
uint32_t hash_data_extract(uint8_t *blob, uint32_t size, uint64_t *out) {
    uint8_t *end = blob + size;

    if (blob + 44 > end)
        goto bounds_error;

    // Check version field at blob+8
    if (bswap32(*(uint32_t*)(blob + 8)) >> 9 < 0x101)
        return 0x128B9;  // version too low

    if (blob + 52 > end)
        goto bounds_error;

    uint32_t offset_raw = *(uint32_t*)(blob + 48);
    if (!offset_raw)
        return 0x224B9;  // no hash data (offset = 0)

    uint32_t offset = bswap32(offset_raw);
    uint8_t *data = blob + offset;

    if (data < blob || data >= end)
        goto bounds_error;

    // Find null terminator
    uint8_t *scan = data + 1;
    while (scan <= end) {
        if (*(scan - 1) == 0) {
            *out = (uint64_t)data;
            return 0x39;
        }
        scan++;
    }

bounds_error:
    return error_handler(25);
}

Caller: cs_selector24_validate (0xfffffff0170359E0)

uint32_t cs_selector24_validate(cs_ctx *a1) {
    uint8_t selector_type = *(uint8_t*)((char*)a1 + 161);

    if (!selector_type)
        return 0x10503;  // no selector set

    if (*((uint8_t*)a1 + 162))
        return 0x23403;  // already validated

    uint32_t r;

    r = check_library_validation(*a1, selector_type);
    if ((r & 0xFF00) != 0) return r;

    r = check_runtime_flag(*a1, selector_type);
    if ((r & 0xFF00) != 0) return r;

    r = check_jit_entitlement(a1, selector_type);
    if ((r & 0xFF00) != 0) return r;

    r = check_hash_flags(a1, selector_type);   // <<<< CHECK #4
    if ((r & 0xFF00) != 0) return r;

    r = check_team_id(a1);
    if ((r & 0xFF00) != 0) return r;

    r = check_srd_entitlement(a1);
    if ((r & 0xFF00) != 0) return r;

    r = check_extended_research(a1);
    if ((r & 0xFF00) != 0) return r;

    r = check_hash_type(a1);
    if ((r & 0xFF00) != 0) return r;

    // All pre-checks passed — now type-specific validation
    switch (selector_type) {
        case 1: r = validate_type1(a1); break;
        case 2: r = validate_type2(a1); break;
        case 3: r = validate_type3(a1); break;
        case 4: r = validate_type4(a1); break;
        case 5: /* no extra check */ break;
        case 6..10: r = validate_type6_10(a1, selector_type); break;
        default: return 0x40103;
    }

    if ((r & 0xFF00) != 0) return r;

    // Mark as validated
    *((uint8_t*)a1 + 162) = selector_type;
    return 3;  // success
}

Top-level: cs_evaluate (0xfffffff017024834)

uint64_t cs_evaluate(cs_session *session) {
    update_state(session, 1, 0);

    if (session->flags & 1) {
        log_error(80, 0);
        goto fatal;
    }

    cs_ctx *ctx = &session->ctx;

    uint32_t r = cs_blob_init(ctx);
    if (BYTE1(r)) goto handle_error;

    r = cs_determine_selector(ctx, NULL);
    if (BYTE1(r)) goto handle_error;

    r = cs_selector24_validate(ctx);
    if (!BYTE1(r)) {
        // Success — return 0
        finalize(session, 1);
        return 0 | (packed_status);
    }
    // ... error handling ...
}

Validation Sub-check Details

[1] check_library_validation (ret 0xA9)

Checks library validation flag. If *(*a1 + 5) & 1 and selector not in [7..10], returns 0x130A9 (fail).

[2] check_runtime_flag (ret 0xA8)

For selector <= 5: checks runtime hardened flag via function pointer at a1[1](). Returns 0x130A8 if runtime enabled and runtime flag set.

[3] check_jit_entitlement (ret 0xA0)

Checks com.apple.developer.cs.allow-jit and com.apple.developer.web-browser-engine.webcontent entitlements. For selector <= 5, also checks against a list of 4 platform entitlements.

[4] check_hash_flags (ret 0xA1) — PATCH TARGET

See detailed analysis above.

[5] check_team_id (ret 0xA2)

Checks team ID against 6 known Apple team IDs using entitlement lookup functions.

[6] check_srd_entitlement (ret 0xAA)

Checks com.apple.private.security-research-device entitlement.

[7] check_extended_research (ret 0xAB)

Checks com.apple.private.security-research-device.extended-research-mode entitlement.

[8] check_hash_type (ret 0xAC)

Re-extracts hash data and validates hash algorithm type via sub_FFFFFFF01702EDF4.


Why the NOP Patches Failed

Test results:

Patch 1 (NOP LDR) Patch 2 (NOP BL) Result
OFF OFF Boots
ON OFF Panic
OFF ON Panic
ON ON Panic

Root cause analysis:

With no patches (dev-only): The function runs normally. For our binaries:

  • hash_flags_extract returns proper flags from CS blob
  • hash_data_extract returns the hash data pointer
  • The consistency check has_data == exempt evaluates to 1 == 0 (has data, not exempt) → mismatch → passes (B.NE taken)
  • The type-specific logic returns 0xA1 (pass)

NOP LDR only (Patch 1): x1 retains the value of a2 (selector type, a small number like 5 or 10) instead of cs_size (the actual blob size). When hash_flags_extract runs, the bounds check blob + 44 <= blob + size uses the wrong size. If a2 < 44, the check fails → error path → flags stays 0. Then exempt = 0, and if has_data = 1 → mismatch passes, but later type-specific logic with (flags & 2) == 0 may return 0x22DA1 (FAIL) depending on selector type.

NOP BL only (Patch 2): hash_flags_extract never runs → flags = 0 (initialized to 0). So exempt = 0. If hash data exists (has_data = 1), consistency check passes (mismatch: 1 != 0). But then:

  • For type 1-2: (flags & 2) == 0 → returns 0x22DA1 FAIL
  • For type 3-5: (flags & 2) == 0 → returns 0xA1 PASS
  • For type > 5: returns 0xA1 PASS

So if the binary's selector type is 1 or 2, NOP'ing the BL causes failure.

Both patches: Similar to NOP BL — hash_flags_extract is NOP'd so flags=0, but NOP LDR also corrupts x1 (which is unused since BL is also NOP'd, so no effect). Net result same as NOP BL only.

Conclusion: The patches were counterproductive. The function already returns PASS for legitimately signed binaries with dev patches. The NOPs corrupt state and cause it to FAIL.


Correct Patch Strategy (for future unsigned code)

When JB payloads run unsigned/modified code, check_hash_flags may legitimately fail. The correct fix is to make it always return 0xA1 (PASS).

Patch 2 instructions at raw offset 0x313B0:

BEFORE:
  0x313B0:  mov x19, x1          (E1 03 17 AA → actually this encodes to different bytes)
  0x313B4:  mov x20, x0

AFTER:
  0x313B0:  mov w0, #0xa1        (20 14 80 52)
  0x313B4:  b   +0x6C            (1B 00 00 14)  → jumps to exit at 0x31420

The prologue (0x31398-0x313AC) has already saved all callee-saved registers. The exit path at 0x31420 restores them and does RETAB. This is safe.

Alternative: Patch error returns to PASS

Replace both error-returning MOVs with the PASS value:

0x31418:  mov w0, #0xA1    (20 14 80 52)     was: mov w0, #0x30A1
0x3141C:  nop              (1F 20 03 D5)     was: movk w0, #1, lsl #16
0x31454:  mov w0, #0xA1    (20 14 80 52)     was: mov w0, #0x2DA1
0x31458:  nop              (1F 20 03 D5)     was: movk w0, #2, lsl #16

This preserves the original logic flow but makes all paths return PASS.


Current Status

The 2 JB TXM patches (patch_selector24_hash_extraction_nop) are disabled (commented out in txm_jb.py). The JB variant now boots identically to dev for TXM validation. When unsigned code execution is needed, apply one of the recommended patches above.