Summary
-[ByteBrewiOSPlugin RequestRemoteConfigs:]'s completion handler branches solely on
[response statusCode] == 200 and never inspects the NSError * argument. When NSURLSession
delivers the response headers but the body transfer then fails, the handler is invoked with
response = a valid 200 NSHTTPURLResponse, data = nil, error = non-nil. The block
takes the 200 path and calls +[NSJSONSerialization JSONObjectWithData:options:error:] with nil
data, which raises NSInvalidArgumentException. There is no @try/@catch in the block, and it
runs on a libdispatch worker thread, so the exception is uncaught and the host app terminates.
This is our single highest-volume crash signature in production.
Crash report
Fatal Exception: NSInvalidArgumentException
0 CoreFoundation 0x115244 __exceptionPreprocess
1 libobjc.A.dylib 0x31224 objc_exception_throw
2 Foundation 0xf13d8 -[_NSJSONReader parseData:options:error:]
3 <App> 0xda8ad4 __42-[ByteBrewiOSPlugin RequestRemoteConfigs:]_block_invoke + 946
4 <App> 0x161e68 __InstrumentDataTaskWithRequestCompletionHandler_block_invoke_2 (FPRNSURLSessionInstrument.m)
5 <App> 0x161e68 __InstrumentDataTaskWithRequestCompletionHandler_block_invoke_2 (FPRNSURLSessionInstrument.m)
6 CFNetwork 0xa3a7c __40-[__NSURLSessionLocal taskForClassInfo:]_block_invoke
7 CFNetwork 0xa3844 __49-[__NSCFLocalSessionTask _task_onqueue_didFinish]_block_invoke_2
8 libdispatch.dylib 0x19a8 _dispatch_call_block_and_release
...
Frames 4–5 are FirebasePerformance's NSURLSession swizzle passing the arguments straight
through; they are not implicated in the failure.
Root cause (from disassembly)
Static analysis of ByteBrewNativeiOSPlugin.framework → ByteBrewiOSPlugin.o,
___42-[ByteBrewiOSPlugin RequestRemoteConfigs:]_block_invoke @ 0x3a40 (arm64):
3a58: mov x20, x2 ; x2 = response
3a5c: mov x21, x0 ; x0 = block
3a60: mov x0, x1 ; x1 = data
3a64: bl _objc_retain ; x19 = data
3a6c: mov x0, x20
3a70: bl _objc_retain ; x20 = response
3a88: bl _objc_msgSend$statusCode ; [response statusCode]
3a8c: cmp x0, #0xc8 ; == 200 ?
3a90: b.ne 0x3b3c ; -> finished(NO)
...
3ab8: str xzr, [sp, #0x8] ; NSError *err = nil
3abc: add x4, sp, #0x8
3ac0: mov x2, x19 ; <-- data passed unchecked
3ac4: mov x3, #0x0 ; options: 0
3ac8: bl "_objc_msgSend$JSONObjectWithData:options:error:" ; <-- throws when data == nil
3ae4: ... _objc_storeStrong ; self->remoteConfigs = json
3af4: ... blr x8 (w1 = 1) ; finished(YES)
3b04: cbz x23, 0x3b28 ; if (err) NSLog(...)
Notes:
- The
NSError * block argument (x3) is loaded into no register and tested nowhere in the
block — the only cbz is on the parser's out-error, checked after finished(YES)
has already been invoked.
- No landing pads /
objc_begin_catch exist in block_invoke; the @try handler visible in
the archive belongs to the enclosing method, around request construction only.
Reconstructed source:
[[[NSURLSession sharedSession] dataTaskWithRequest:request
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (((NSHTTPURLResponse *)response).statusCode == 200) {
NSLog(@"ByteBrew: Retrieved Configs");
NSError *err = nil;
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data // data may be nil
options:0
error:&err];
self->remoteConfigs = json;
finished(YES);
if (err) NSLog(@"…%@", err);
} else {
finished(NO);
}
}] resume];
NSURLSession explicitly permits (data == nil, response != nil, error != nil): the response
head is reported as soon as headers arrive, and a later body failure — NSURLErrorNetworkConnectionLost
(-1005), NSURLErrorTimedOut (-1001), NSURLErrorCancelled (-999) — nils out data while
leaving the 200 NSHTTPURLResponse in place. An empty 200 body is harmless (zero-length
NSData returns nil + error rather than throwing); only genuinely nil data raises.
Reproduction
- Call
+[ByteBrewNativeiOSPlugin LoadRemoteConfigs:] after a successful Initialize.
- While
GET https://api.bytebrew.io/api/game/configurations/remote/<gameID> is in flight —
after response headers, before the body completes — drop the connection (airplane mode,
Network Link Conditioner "100% Loss", or nc proxy that sends HTTP/1.1 200 OK\r\n\r\n
and then closes without a body).
- The app terminates with
NSInvalidArgumentException from _NSJSONReader parseData:options:error:.
We see this at meaningful volume in the wild without any special conditions. Our app is a VPN
client: bringing the tunnel up or down resets in-flight connections, which reliably produces the
"headers received, body killed" shape. Any app on a mobile network hits the same window; a VPN
just makes it common.
Impact
- Hard crash of the host app, not recoverable by the integrator.
- Not interceptable from the app side:
LoadRemoteConfigs: constructs and owns the
NSURLSessionDataTask internally, so there is no seam to wrap the completion handler.
LoadRemoteConfigs: issues a fresh network request on every call (guarded only by
initializationCalled, no result cache), so each call is another chance to crash.
Suggested fix
Validate the transport outcome before parsing:
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
NSHTTPURLResponse *http = [response isKindOfClass:[NSHTTPURLResponse class]]
? (NSHTTPURLResponse *)response : nil;
if (error != nil || data == nil || http.statusCode != 200) {
NSLog(@"ByteBrew: Config request failed (status %ld, error %@)",
(long)http.statusCode, error);
finished(NO);
return;
}
NSError *parseError = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError];
if (![json isKindOfClass:[NSDictionary class]]) {
NSLog(@"ByteBrew: Config parse failed: %@", parseError);
finished(NO);
return;
}
self->remoteConfigs = json;
finished(YES);
}
Two secondary points folded into the above:
finished(YES) is currently called even when parsing fails, before the err check.
Integrators who trust the BOOL alone (rather than also calling HasRemoteConfigs) will read
configs that were never populated. The callback should report NO on parse failure.
- A
nil/non-dictionary top-level JSON value should not be stored into remoteConfigs.
The same unchecked-data pattern appears in
-[ByteBrewiOSPlugin ValidatePurchaseData:finishedValidationRequest:] and is worth auditing
across the other dataTaskWithRequest: call sites in ByteBrewiOSPlugin.m.
Environment
ByteBrewNativeiOSPlugin.framework, internal version string 0.1.4
- Built Release-iphoneos, Apple clang 15.0.0 (clang-1500.1.0.2.5), iPhoneOS 17.2 SDK
- Integrated via SwiftPM binary target; host app targets iOS 18.0+, Swift 6.2
- Crashes observed on device across iOS versions; FirebasePerformance also installed (not causal)
Summary
-[ByteBrewiOSPlugin RequestRemoteConfigs:]'s completion handler branches solely on[response statusCode] == 200and never inspects theNSError *argument. WhenNSURLSessiondelivers the response headers but the body transfer then fails, the handler is invoked with
response= a valid 200NSHTTPURLResponse,data= nil,error= non-nil. The blocktakes the 200 path and calls
+[NSJSONSerialization JSONObjectWithData:options:error:]with nildata, which raises
NSInvalidArgumentException. There is no@try/@catchin the block, and itruns on a
libdispatchworker thread, so the exception is uncaught and the host app terminates.This is our single highest-volume crash signature in production.
Crash report
Frames 4–5 are FirebasePerformance's
NSURLSessionswizzle passing the arguments straightthrough; they are not implicated in the failure.
Root cause (from disassembly)
Static analysis of
ByteBrewNativeiOSPlugin.framework→ByteBrewiOSPlugin.o,___42-[ByteBrewiOSPlugin RequestRemoteConfigs:]_block_invoke@0x3a40(arm64):Notes:
NSError *block argument (x3) is loaded into no register and tested nowhere in theblock — the only
cbzis on the parser's out-error, checked afterfinished(YES)has already been invoked.
objc_begin_catchexist inblock_invoke; the@tryhandler visible inthe archive belongs to the enclosing method, around request construction only.
Reconstructed source:
NSURLSessionexplicitly permits(data == nil, response != nil, error != nil): the responsehead is reported as soon as headers arrive, and a later body failure —
NSURLErrorNetworkConnectionLost(-1005),
NSURLErrorTimedOut(-1001),NSURLErrorCancelled(-999) — nils outdatawhileleaving the 200
NSHTTPURLResponsein place. An empty 200 body is harmless (zero-lengthNSDatareturns nil + error rather than throwing); only genuinely nildataraises.Reproduction
+[ByteBrewNativeiOSPlugin LoadRemoteConfigs:]after a successfulInitialize.GET https://api.bytebrew.io/api/game/configurations/remote/<gameID>is in flight —after response headers, before the body completes — drop the connection (airplane mode,
Network Link Conditioner "100% Loss", or
ncproxy that sendsHTTP/1.1 200 OK\r\n\r\nand then closes without a body).
NSInvalidArgumentExceptionfrom_NSJSONReader parseData:options:error:.We see this at meaningful volume in the wild without any special conditions. Our app is a VPN
client: bringing the tunnel up or down resets in-flight connections, which reliably produces the
"headers received, body killed" shape. Any app on a mobile network hits the same window; a VPN
just makes it common.
Impact
LoadRemoteConfigs:constructs and owns theNSURLSessionDataTaskinternally, so there is no seam to wrap the completion handler.LoadRemoteConfigs:issues a fresh network request on every call (guarded only byinitializationCalled, no result cache), so each call is another chance to crash.Suggested fix
Validate the transport outcome before parsing:
Two secondary points folded into the above:
finished(YES)is currently called even when parsing fails, before theerrcheck.Integrators who trust the
BOOLalone (rather than also callingHasRemoteConfigs) will readconfigs that were never populated. The callback should report
NOon parse failure.nil/non-dictionary top-level JSON value should not be stored intoremoteConfigs.The same unchecked-
datapattern appears in-[ByteBrewiOSPlugin ValidatePurchaseData:finishedValidationRequest:]and is worth auditingacross the other
dataTaskWithRequest:call sites inByteBrewiOSPlugin.m.Environment
ByteBrewNativeiOSPlugin.framework, internal version string0.1.4