众所周知 macOS 默认的 Alt/Command+Tab 行为十分智障,已经被吐槽好些年了,有些 APP 没有打开窗口,依然会显示在切换列表中,切换过去的时候什么也不会发生。

我预期的行为是 Alt/Command+Tab 列表中应该只显示有窗口存在的 APP,并且每个工作区应该仅持有当前工作区 APP 的列表,我不喜欢 Alt/Command+Tab 切换 APP 会切换到其它工作区以及 “无事发生” 的行为。

系统版本: macOS 26.1 (25B78)

这篇文章记录修改过程。

主要是两个功能:

  1. 从切换列表中剔除所有没有可见窗口的 App。

  2. 各工作区独立管理自己的 App 切换列表,不会再跳到其他工作区。

先上最终效果图

1. 旧

old_dock

2. 新

new_dock

修改 Dock.app

别的先不管,先给头文件 dump 出来看看

1
2
3
ipsw class-dump --headers \
    -o DockHeaders \
    /System/Library/CoreServices/Dock.app/Contents/MacOS/Dock

随便搜索一下关键词 switch 发现 ASAppSwitcher 接口,定义如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@interface ASAppSwitcher : NSObject <BezelIconList> {
    /* instance variables */
    BezelIconList *_bezelIconList;
    NSMutableArray *_processes;
    id _justHiddenASN;
    void * _noteID;
    NSObject<OS_dispatch_source> *_keyRepeatTimer;
    NSMapTable *_dragAcceptanceMap;
    struct __CFDictionary * _notifications;
    ASContinuityBestApplication *_continuityApp;
    int _selectionChangeCount;
    ECAXObserverStoreWrapper *_observerStore;
    _Bool _shown;
    _Bool _hotKeysTurnedOff;
    _Bool _destroySpace;
    _Bool _registeredForStatusLabelNotification;
    _Bool _hasHandoff;
}

还有一个 show 方法

1
- (void)show:(_Bool)show;

每次按下 Alt/Command+Tab 的时候都会调用 show,然后展示 NSMutableArray *_processes; 储存的 APP 列表

也就是说,只要 hook ASAppSwitcher show 然后修改 _processes 就行了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static void (*orig_show)(id, SEL, BOOL);

void my_ASAppSwitcher_show(id self, SEL _cmd, BOOL show) {
    Ivar processesIvar = class_getInstanceVariable([self class], "_processes");
    NSMutableArray *processes = object_getIvar(self, processesIvar);
    // TODO
    orig_show(self, _cmd, show);
}

Class cls = objc_getClass("ASAppSwitcher");  
SEL sel = @selector(show:);
Method method = class_getInstanceMethod(cls, sel);
orig_show = (void *)method_getImplementation(method);
method_setImplementation(method, (IMP)my_ASAppSwitcher_show);

但说起来容易,实际上并不知道 _processes 里面到底是个什么东西,要实现我的需求,首先要能获取 _processes 中的具体信息

这时我发现有两个方法 imageChanged progressChanged,看签名可能和 _processes 有关系,也许 _processes 携带了 CPSProcessSerNum

如果能拿到 CPSProcessSerNum 我们就可以通过 GetProcessPID(const ProcessSerialNumber *psn, pid_t *pid) 获取到进程 ID

1
2
- (void)imageChanged:(id)changed forProcess:(struct CPSProcessSerNum { unsigned int x0; unsigned int x1; })process;
- (void)progressChanged:(float)changed forProcess:(struct CPSProcessSerNum { unsigned int x0; unsigned int x1; })process;

我决定先模糊测试一下是否可以找到 PSN,结果我发现

1
2
3
4
5
6
7
# memory dump
b9 a4 b3 ee 01 80 04 01 80 4d 00 94 2f 23 00 00
00 00 00 00 4b b0 04 00 00 00 00 00 00 00 00 00
20 f4 a3 1e 09 00 00 00 f0 f3 a3 1e 09 00 00 00
c0 f3 a3 1e 09 00 00 00 00 00 00 00 00 00 00 00

PSN at offset 16: lo=0, hi=307275, cid=650563

PSN 就在 _processes 中每个元素偏移 16 的位置,这下好办了

先遍历获取进程 ID

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
NSUInteger count = [processes count];
  for (NSUInteger i = 0; i < count; i++) {
    void *process = [processes objectAtIndex:i];
    // PSN is at offset 16 in __NSCFType
    unsigned char *bytes = (unsigned char *)process;
    ProcessSerialNumber *psn = (ProcessSerialNumber *)(bytes + 16);
    
    pid_t pid = 0;
    OSStatus err = GetProcessPID(psn, &pid);
    // TODO
}

获取当前屏幕上的所有窗口

1
CFArrayRef windowList = CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements, kCGNullWindowID);

如果窗口 pid 存在于 _processes 中,并且 layer 为 0,就表示它是实际有效的窗口,因此 _processes 里只需要保留 layer == 0 的项即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BOOL hasValidWindow = NO;
if (windowList) {
  CFIndex count = CFArrayGetCount(windowList);
  for (CFIndex j = 0; j < count; j++) {
    CFDictionaryRef window = CFArrayGetValueAtIndex(windowList, j);
    CFNumberRef windowPID = CFDictionaryGetValue(window, kCGWindowOwnerPID);
    int ownerPID = 0;
    if (windowPID) CFNumberGetValue(windowPID, kCFNumberIntType, &ownerPID);
    if (ownerPID == pid) {
      CFNumberRef layerNum = CFDictionaryGetValue(window, kCGWindowLayer);
      int layer = -1;
      if (layerNum) CFNumberGetValue(layerNum, kCFNumberIntType, &layer);
      if (layer == 0) {
        hasValidWindow = YES;
        break;
      }
    }
  }
    CFRelease(windowList);
}

if (!hasValidWindow) [indexesToRemove addIndex:i];

[processes removeObjectsAtIndexes:indexesToRemove]

沙箱注入器

实际上 Dock.app 作为系统核心应用,并没有开启沙箱,但我还是编写了一个沙箱注入器,用于处理开启了沙箱的其它 APP

主流远程线程注入都是这个过程

  1. 获取目标进程的控制权(task port)
  2. 在目标进程中分配内存
  3. 写入执行代码(shellcode)
  4. 创建远程线程执行 shellcode
  5. shellcode 调用 dlopen 加载我们的 dylib

但是 macOS 有沙箱机制的存在,调用 dlopen 加载我们的 dylib 通常会找不到路径。

所以需要在加载我们的 dylib 之前开通沙箱权限,一般有三种方式

  1. 直接重签名权限移除沙箱、添加文件路径、修改二进制文件,这种方式非常粗暴,可能导致 APP 产生异常行为,尤其是和多进程相关的,非常不推荐

  2. 找到一个即便开启了沙箱也可以访问到的公用路径,限制大,可能有权限问题,不推荐

  3. 直接在 shellcode 中临时授权,这种方式不会破坏原有签名、不会修改二进制文件,即便出了问题也就是进程崩溃,只要重启一下进程就行了,比较安全

我这里使用方法 3

构造 loader.s

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
        // 在可写区域创建,只是为了免一次copy
        .section __DATA,__data

        // 全局标签,主要为了计算偏移填充数据
        .global _m_start
        .global _m_end
        .global _m_pthread_create_addr
        .global _m_sandbox_consume_addr
        .global _m_dlopen_addr
        .global _m_payload_path
        .global _m_sandbox_token

// shellcode 开始地址,m_end - m_start 就可以获取大小
_m_start:
        sub sp, sp, #0x30
        stp x29, x30, [sp, #0x20]
        add x29, sp, #0x20

        stur w0, [x29, #-0x4]
        str x1, [sp, #0x10]

        add x0, sp, #0x8
        mov x8, #0
        str x8, [sp, #0x8]
        mov x1, x8
        adr x2, m_thread_entry
        paciza x2
        mov x3, x8

        adr x9, _m_pthread_create_addr
        ldr x9, [x9]
        blr x9

        // 0x79616265 写入一个自定义的标识到 x0 寄存器
        movz x0, #0x6265
        movk x0, #0x7961, lsl #16

        // 无限循环等待,主程序只要读取 x0 寄存器,就可以知道 shellcode 已经执行到这里,可以执行其它操作了
        b .

        .align 4
m_thread_entry:
        pacibsp
        sub sp, sp, #0x30
        stp x29, x30, [sp, #0x20]
        add x29, sp, #0x20

        stur w0, [x29, #-0x4]
        str x1, [sp, #0x10]

        adr x0, _m_sandbox_token
        adr x9, _m_sandbox_consume_addr
        ldr x9, [x9]
        blr x9

        mov x1, #1
        adr x0, _m_payload_path
        adr x9, _m_dlopen_addr
        ldr x9, [x9]
        blr x9

        ldp x29, x30, [sp, #0x20]
        add sp, sp, #0x30
        retab

        .align 3
_m_pthread_create_addr:
        .quad 0x0

        .align 3
_m_sandbox_consume_addr:
        .quad 0x0

        .align 3
_m_dlopen_addr:
        .quad 0x0

// payload_path 限制 128 字节,大部分时候足够
        .align 3
_m_payload_path:
        .zero 128

// token 中包含 payload_path 所以长一点,限制 256 字节
        .align 3
_m_sandbox_token:
        .zero 256

// shellcode 结束地址
_m_end:

引入标签计算偏移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
extern char m_start[];
extern char m_end[];
extern char m_pthread_create_addr[];
extern char m_sandbox_consume_addr[];
extern char m_dlopen_addr[];
extern char m_payload_path[];
extern char m_sandbox_token[];

unsigned char *SHELLCODE = (unsigned char *)m_start;
const uintptr_t SHELLCODE_SIZE = m_end - m_start;
const uintptr_t PTHREAD_CREATE = m_pthread_create_addr - m_start;
const uintptr_t SANDBOX_CONSUME = m_sandbox_consume_addr - m_start;
const uintptr_t DLOPEN = m_dlopen_addr - m_start;
const uintptr_t PAYLOAD_PATH = m_payload_path - m_start;
const uintptr_t SANDBOX_TOKEN = m_sandbox_token - m_start;

获取 Task Port

1
task_for_pid(mach_task_self(), pid, &task)

分配内存

1
2
3
mach_vm_allocate(task, &stack, stack_size, VM_FLAGS_ANYWHERE)
mach_vm_write(task, stack, (vm_address_t)&stack_contents, sizeof(uint64_t))
vm_protect(task, stack, stack_size, 1, VM_PROT_READ | VM_PROT_WRITE)

签发沙箱 token

1
char *sandbox_token = sandbox_extension_issue_file(APP_SANDBOX_READ, payload_path, 0);

修补 shellcode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
uint64_t pcfmt_address = (uint64_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "pthread_create_from_mach_thread"), 
    ptrauth_key_function_pointer
);

uint64_t dlopen_address = (uint64_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "dlopen"), 
    ptrauth_key_function_pointer
);

uint64_t sandbox_consume_address = (uint64_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "sandbox_extension_consume"), 
    ptrauth_key_function_pointer
);
memcpy(SHELLCODE + PTHREAD_CREATE, &pcfmt_address, sizeof(uint64_t));
memcpy(SHELLCODE + SANDBOX_CONSUME, &sandbox_consume_address, sizeof(uint64_t));
memcpy(SHELLCODE + DLOPEN, &dlopen_address, sizeof(uint64_t));
memcpy(SHELLCODE + PAYLOAD_PATH, payload_path, strlen(payload_path));
memcpy(SHELLCODE + SANDBOX_TOKEN, sandbox_token, strlen(sandbox_token));

写入 shellcode

1
2
3
mach_vm_allocate(task, &code, SHELLCODE_SIZE, VM_FLAGS_ANYWHERE)
mach_vm_write(task, code, (vm_address_t)SHELLCODE, SHELLCODE_SIZE)
vm_protect(task, code, SHELLCODE_SIZE, 0, VM_PROT_EXECUTE | VM_PROT_READ)

设置线程状态并执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 设置 PC(程序计数器)
__darwin_arm_thread_state64_set_pc_fptr(thread_state, ptrauth_sign_unauthenticated((void *)code, ptrauth_key_asia, 0));
  
// 设置 SP(栈指针)
__darwin_arm_thread_state64_set_sp(thread_state, stack + (stack_size / 2));

thread_create(task, &thread);
thread_convert_thread_state(
      thread, 
      2, 
      thread_flavor, 
      (thread_state_t)&thread_state,
      thread_flavor_count, 
      (thread_state_t)&machine_thread_state,
      &machine_thread_flavor_count
  );

thread_create_running(task, thread_flavor, (thread_state_t)&machine_thread_state, machine_thread_flavor_count, &thread);

检查 x0 寄存器判断 shellcode 是否执行完成

1
2
3
4
5
thread_get_state(thread, thread_flavor, (thread_state_t)&thread_state, &thread_flavor_count);
if (thread_state.__x[0] == 0x79616265) // 执行完成

// 如果执行完成就直接杀死 shellcode 线程
thread_terminate(thread);

享受

当然还有很多改进的点,例如 payload_pathtoken 可以直接传一个指针而不是限定长度,这样可以缩小 shellcode 大小;在沙箱授权之前可以检查进程是否已经开启了沙箱,更为严谨,虽然即便没有开启沙箱但是添加了沙箱授权也不会有什么问题就是

完整代码: 去我的 Github 中自己找吧,写这篇文章的时候还没有整合到单独仓库中 😁

感谢:

https://github.com/koekeishiya https://github.com/jslegendre

提供了无沙箱授权的基本注入器参考

https://github.com/opa334

从大佬的代码中第一次知道沙箱注入的操作

https://github.com/GRAVITYDIV10

对 C/ASM 方面的帮助