众所周知 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
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
.section __DATA, __data

// 全局标签,用于计算偏移
.global ___shellcode_start
.global ___shellcode_end
.global ___patch_pthread_create
.global ___patch_sandbox_consume
.global ___patch_dlopen
.global ___patch_dlerror
.global ___patch_payload_path
.global ___patch_sandbox_token_read
.global ___patch_sandbox_token_exec
.global ___patch_error_buffer

___shellcode_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, __thread_entry
	paciza x2
	mov    x3, x8

	adr x9, ___patch_pthread_create
	ldr x9, [x9]
	blr x9

	movz x0, #0x4e45
	movk x0, #0x444f, lsl #16

	// 无限循环等待,主程序读取 x0 寄存器判断 shellcode 是否执行完成
	b .

	.align 4

__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 x9, ___patch_sandbox_consume
	ldr x9, [x9]

	// 检查并消费 read token
	adr x0, ___patch_sandbox_token_read
	ldr x0, [x0]
	cbz x0, 1f
	blr x9

1:
	// 检查并消费 exec token
	adr x0, ___patch_sandbox_token_exec
	ldr x0, [x0]
	cbz x0, 2f
	blr x9

2:
	// 调用 dlopen 加载 dylib
	mov x1, #1
	adr x0, ___patch_payload_path
	ldr x0, [x0]
	adr x9, ___patch_dlopen
	ldr x9, [x9]
	blr x9

	// 保存 dlopen 返回值到错误缓冲区
	adr x9, ___patch_error_buffer
	ldr x9, [x9]
	str x0, [x9]

	// 如果 dlopen 成功则跳过错误处理
	cbnz x0, 3f

	// dlopen 失败,调用 dlerror 获取错误信息
	adr x9, ___patch_dlerror
	ldr x9, [x9]
	blr x9

	// 保存 dlerror 返回的字符串指针
	adr x9, ___patch_error_buffer
	ldr x9, [x9]
	str x0, [x9, #8]

3:
	movz x0, #0x4e45
	movk x0, #0x444f, lsl #16

	b .

	.align 3
___patch_pthread_create:
	.quad 0x0

	.align 3
___patch_sandbox_consume:
	.quad 0x0

	.align 3
___patch_dlopen:
	.quad 0x0

	.align 3
___patch_dlerror:
	.quad 0x0

	.align 3
___patch_payload_path:
	.quad 0x0

	.align 3
___patch_sandbox_token_read:
	.quad 0x0

	.align 3
___patch_sandbox_token_exec:
	.quad 0x0

	.align 3
___patch_error_buffer:
	.quad 0x0

___shellcode_end:

引入标签计算偏移

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
extern char __shellcode_start[];
extern char __shellcode_end[];
extern char __patch_pthread_create[];
extern char __patch_sandbox_consume[];
extern char __patch_dlopen[];
extern char __patch_dlerror[];
extern char __patch_payload_path[];
extern char __patch_sandbox_token_read[];
extern char __patch_sandbox_token_exec[];
extern char __patch_error_buffer[];

unsigned char *SHELLCODE = (unsigned char *)__shellcode_start;
const uintptr_t SHELLCODE_SIZE = __shellcode_end - __shellcode_start;
const uintptr_t PTHREAD_CREATE = __patch_pthread_create - __shellcode_start;
const uintptr_t SANDBOX_CONSUME = __patch_sandbox_consume - __shellcode_start;
const uintptr_t DLOPEN = __patch_dlopen - __shellcode_start;
const uintptr_t DLERROR = __patch_dlerror - __shellcode_start;
const uintptr_t PAYLOAD_PATH = __patch_payload_path - __shellcode_start;
const uintptr_t ERROR_BUFFER = __patch_error_buffer - __shellcode_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_protect(task, stack, stack_size, TRUE, VM_PROT_READ | VM_PROT_WRITE);
mach_vm_write(task, stack, (vm_address_t)&stack_contents, sizeof(uintptr_t));

分配错误缓冲区和 dylib 路径内存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 分配错误缓冲区用于接收 dlopen 和 dlerror 的返回值
mach_vm_address_t error_buffer_address = 0;
mach_vm_allocate(task, &error_buffer_address, sizeof(uintptr_t), VM_FLAGS_ANYWHERE);
mach_vm_protect(task, error_buffer_address, sizeof(uintptr_t), TRUE, VM_PROT_READ | VM_PROT_WRITE);

// 分配内存存储 dylib 路径字符串
mach_vm_address_t payload_path_address = 0;
size_t payload_path_len = strlen(payload_path) + 1;
mach_vm_allocate(task, &payload_path_address, payload_path_len, VM_FLAGS_ANYWHERE);
mach_vm_protect(task, payload_path_address, payload_path_len, TRUE, VM_PROT_READ | VM_PROT_WRITE);
mach_vm_write(task, payload_path_address, (vm_address_t)payload_path, payload_path_len);

检查沙箱权限并签发相应的 token

 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
// 检查进程是否需要文件读取权限
int sandbox_file_read_data = sandbox_check(pid, "file-read-data", 
    SANDBOX_FILTER_PATH | SANDBOX_CHECK_NO_REPORT, payload_path);
    
// 检查进程是否需要文件执行权限
int sandbox_file_map_executable = sandbox_check(pid, "file-map-executable", 
    SANDBOX_FILTER_PATH | SANDBOX_CHECK_NO_REPORT, payload_path);

if (sandbox_file_read_data) {
    // 签发读取权限 token
    char *token = sandbox_extension_issue_file(APP_SANDBOX_READ, payload_path, 0);
    
    // 在目标进程中分配内存存储 token
    mach_vm_address_t token_address = 0;
    size_t token_len = strlen(token) + 1;
    mach_vm_allocate(task, &token_address, token_len, VM_FLAGS_ANYWHERE);
    mach_vm_protect(task, token_address, token_len, TRUE, VM_PROT_READ | VM_PROT_WRITE);
    mach_vm_write(task, token_address, (vm_address_t)token, token_len);
    
    // 将 token 地址写入 shellcode
    memcpy(SHELLCODE + SANDBOX_TOKEN_READ, &token_address, sizeof(uintptr_t));
}

if (sandbox_file_map_executable) {
    // 签发执行权限 token
    char *token = sandbox_extension_issue_file(APP_SANDBOX_EXECUTABLE, payload_path, 0);
    
    mach_vm_address_t token_address = 0;
    size_t token_len = strlen(token) + 1;
    mach_vm_allocate(task, &token_address, token_len, VM_FLAGS_ANYWHERE);
    mach_vm_protect(task, token_address, token_len, TRUE, VM_PROT_READ | VM_PROT_WRITE);
    mach_vm_write(task, token_address, (vm_address_t)token, token_len);
    
    // 将 token 地址写入 shellcode
    memcpy(SHELLCODE + SANDBOX_TOKEN_EXEC, &token_address, sizeof(uintptr_t));
}

修补 shellcode

 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
// 获取需要的函数地址并去除指针签名
uintptr_t pcfmt_address = (uintptr_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "pthread_create_from_mach_thread"), 
    ptrauth_key_function_pointer
);

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

uintptr_t dlerror_address = (uintptr_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "dlerror"), 
    ptrauth_key_function_pointer
);

uintptr_t sandbox_consume_address = (uintptr_t)ptrauth_strip(
    dlsym(RTLD_DEFAULT, "sandbox_extension_consume"), 
    ptrauth_key_function_pointer
);

// 将函数地址和数据地址写入 shellcode
memcpy(SHELLCODE + PTHREAD_CREATE, &pcfmt_address, sizeof(uintptr_t));
memcpy(SHELLCODE + DLOPEN, &dlopen_address, sizeof(uintptr_t));
memcpy(SHELLCODE + DLERROR, &dlerror_address, sizeof(uintptr_t));
memcpy(SHELLCODE + SANDBOX_CONSUME, &sandbox_consume_address, sizeof(uintptr_t));
memcpy(SHELLCODE + ERROR_BUFFER, &error_buffer_address, sizeof(uintptr_t));
memcpy(SHELLCODE + PAYLOAD_PATH, &payload_path_address, sizeof(uintptr_t));

写入 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);
mach_vm_protect(task, code, SHELLCODE_SIZE, FALSE, VM_PROT_EXECUTE | VM_PROT_READ);

设置线程状态并执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 设置 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_terminate(thread);
thread_create_running(task, thread_flavor, (thread_state_t)&machine_thread_state, machine_thread_flavor_count, &thread);

检查 x0 寄存器判断 shellcode 是否执行完成并处理错误

 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
usleep(10000);

for (int i = 0; i < 10; ++i) {
    thread_get_state(thread, thread_flavor, 
        (thread_state_t)&thread_state, &thread_flavor_count);
    
    if (thread_state.__x[0] == 0x444f4e45) {
        // shellcode 执行完成,读取错误缓冲区
        uintptr_t error_buffer[2] = {0};
        size_t maxSize = sizeof(error_buffer);
        vm_read_overwrite(task, error_buffer_address, 
            sizeof(error_buffer), (vm_address_t)error_buffer, &maxSize);
        
        if (!error_buffer[0]) {
            // dlopen 失败,读取错误信息
            // 先计算错误字符串长度
            size_t len = 0;
            char c;
            do {
                size_t sz = sizeof(c);
                vm_read_overwrite(task, error_buffer[1] + len++, 
                    sizeof(c), (vm_address_t)&c, &sz);
            } while (c != '\0');
            
            // 读取完整的错误信息
            char *error_msg = malloc(len);
            size_t sz = len;
            vm_read_overwrite(task, error_buffer[1], len, 
                (vm_address_t)error_msg, &sz);
            fprintf(stderr, "remote dlerror: %s\n", error_msg);
            free(error_msg);
        }
        break;
    }
    
    usleep(20000);
}

// 终止 shellcode 线程
thread_terminate(thread);

享受

可改进的点:目前注入器并不完善,即便现在处理了沙箱,实际上也依然会受到 file-map-executable 以及 library-validation 限制,当然后者可以通过关闭 mfi 来解决,而前者需要实现一个 macho-o loader 直接将 dylib 转为 shellocode 在内存执行不能落地,说简单也简单说复杂也复杂,不过现在已经够用了以后再说吧。

完整代码: 去我的 Github 中自己找吧,写这篇文章的时候还没有整合到单独仓库中 😁 而且本文目前提到的代码完全可以还原所有工作

感谢:

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

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

https://github.com/opa334

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

https://github.com/GRAVITYDIV10

对 C/ASM 方面的帮助