Skip to content

Crash: RequestRemoteConfigs: passes nil NSData to NSJSONSerialization when a 200 response body fails mid-transfer #1

Description

@cuzv

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.frameworkByteBrewiOSPlugin.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

  1. Call +[ByteBrewNativeiOSPlugin LoadRemoteConfigs:] after a successful Initialize.
  2. 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).
  3. 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:

  1. 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.
  2. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions