From 9285d994f9340370d60cadad9644eb2262afa49c Mon Sep 17 00:00:00 2001 From: dajiaohuang <108231307+dajiaohuang@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:08:18 +0800 Subject: [PATCH 1/3] Escape strings in Crashlytics JSON records --- Crashlytics/CHANGELOG.md | 2 ++ Crashlytics/Crashlytics/Helpers/FIRCLSFile.m | 37 ++++++++++++++++++++ Crashlytics/UnitTests/FIRCLSFileTests.m | 34 ++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index 28e48f45a6d..0b8170e083a 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,4 +1,6 @@ # 13.0.0 +- [fixed] Escape quotes, backslashes, and control characters when writing strings to + Crashlytics JSON records. - [fixed] Safely validate memory reads when executing `DW_OP_deref_size` operations during DWARF stack unwinding (#16550). - [fixed] Fixed an issue casuing a crash while symbolicating stack frames if the binary image path is null. (#16622) diff --git a/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m b/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m index 7afb657770a..0c03702ea65 100644 --- a/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m +++ b/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m @@ -328,6 +328,43 @@ static void FIRCLSFileWriteStringWithSuffix(FIRCLSFile* file, const char* string, size_t length, char suffix) { + bool needsEscaping = false; + for (size_t i = 0; i < length; ++i) { + const unsigned char character = (unsigned char)string[i]; + if (character == '"' || character == '\\' || character < 0x20) { + needsEscaping = true; + break; + } + } + + if (needsEscaping) { + static const char hexDigits[] = "0123456789abcdef"; + size_t segmentStart = 0; + FIRCLSFileWriteToFileDescriptorOrBuffer(file, "\"", 1); + for (size_t i = 0; i < length; ++i) { + const unsigned char character = (unsigned char)string[i]; + if (character != '"' && character != '\\' && character >= 0x20) { + continue; + } + + FIRCLSFileWriteToFileDescriptorOrBuffer(file, string + segmentStart, i - segmentStart); + if (character == '"' || character == '\\') { + const char escapedCharacter[] = {'\\', (char)character}; + FIRCLSFileWriteToFileDescriptorOrBuffer(file, escapedCharacter, sizeof(escapedCharacter)); + } else { + const char escapedControl[] = { + '\\', 'u', '0', '0', hexDigits[character >> 4], hexDigits[character & 0x0f]}; + FIRCLSFileWriteToFileDescriptorOrBuffer(file, escapedControl, sizeof(escapedControl)); + } + segmentStart = i + 1; + } + FIRCLSFileWriteToFileDescriptorOrBuffer(file, string + segmentStart, length - segmentStart); + + char closingString[2] = {'"', suffix}; + FIRCLSFileWriteToFileDescriptorOrBuffer(file, closingString, suffix == 0 ? 1 : 2); + return; + } + // 2 for quotes, 1 for suffix (if present) and 1 more for null character const size_t maxStringSize = FIRCLSStringBufferLength - (suffix == 0 ? 3 : 4); diff --git a/Crashlytics/UnitTests/FIRCLSFileTests.m b/Crashlytics/UnitTests/FIRCLSFileTests.m index 09e3ba2428c..52e7a4c4ba0 100644 --- a/Crashlytics/UnitTests/FIRCLSFileTests.m +++ b/Crashlytics/UnitTests/FIRCLSFileTests.m @@ -150,6 +150,40 @@ - (void)emptyCollectionFollowedByEntryWithFile:(FIRCLSFile *)file #pragma mark - +- (void)testEscapesJSONStrings { + [self jsonEscapingWithFile:&_unbufferedFile filePath:self.unbufferedPath buffered:NO]; + [self jsonEscapingWithFile:&_bufferedFile filePath:self.bufferedPath buffered:YES]; +} + +- (void)jsonEscapingWithFile:(FIRCLSFile *)file + filePath:(NSString *)filePath + buffered:(BOOL)buffered { + NSString *key = @"key\"\n"; + NSString *value = [NSString stringWithFormat: + @"quote\" slash\\ newline\n tab\t control %C", + (unichar)1]; + + FIRCLSFileWriteSectionStart(file, "string_escaping"); + FIRCLSFileWriteHashStart(file); + FIRCLSFileWriteHashEntryNSString(file, [key UTF8String], value); + FIRCLSFileWriteHashEnd(file); + FIRCLSFileWriteSectionEnd(file); + + if (buffered) { + FIRCLSFileFlushWriteBuffer(file); + } + NSData *data = [NSData dataWithContentsOfFile:filePath]; + XCTAssertNotNil(data); + if (data == nil) { + return; + } + NSError *error; + NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + XCTAssertNotNil(root, @"Escaped JSON should parse, got error %@", error); + NSDictionary *section = root[@"string_escaping"]; + XCTAssertEqualObjects(section[key], value); +} + - (void)testHexEncodingString { [self hexEncodingStringWithFile:&_unbufferedFile filePath:self.unbufferedPath buffered:NO]; [self hexEncodingStringWithFile:&_bufferedFile filePath:self.bufferedPath buffered:YES]; From 73b5a3195194b75b4d003c6acdbacea07d194e7f Mon Sep 17 00:00:00 2001 From: dajiaohuang <108231307+dajiaohuang@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:20:31 +0800 Subject: [PATCH 2/3] Format Crashlytics string test --- Crashlytics/UnitTests/FIRCLSFileTests.m | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Crashlytics/UnitTests/FIRCLSFileTests.m b/Crashlytics/UnitTests/FIRCLSFileTests.m index 52e7a4c4ba0..3e1e83787df 100644 --- a/Crashlytics/UnitTests/FIRCLSFileTests.m +++ b/Crashlytics/UnitTests/FIRCLSFileTests.m @@ -159,9 +159,8 @@ - (void)jsonEscapingWithFile:(FIRCLSFile *)file filePath:(NSString *)filePath buffered:(BOOL)buffered { NSString *key = @"key\"\n"; - NSString *value = [NSString stringWithFormat: - @"quote\" slash\\ newline\n tab\t control %C", - (unichar)1]; + NSString *value = + [NSString stringWithFormat:@"quote\" slash\\ newline\n tab\t control %C", (unichar)1]; FIRCLSFileWriteSectionStart(file, "string_escaping"); FIRCLSFileWriteHashStart(file); From 9524a63f252b314de074cc16f9f59fbb645bcd05 Mon Sep 17 00:00:00 2001 From: dajiaohuang <108231307+dajiaohuang@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:53:38 +0800 Subject: [PATCH 3/3] Cover escaped array strings across writer buffer flushes --- Crashlytics/CHANGELOG.md | 6 ++-- Crashlytics/Crashlytics/Helpers/FIRCLSFile.m | 2 ++ Crashlytics/UnitTests/FIRCLSFileTests.m | 38 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md index 0b8170e083a..b9be066651b 100644 --- a/Crashlytics/CHANGELOG.md +++ b/Crashlytics/CHANGELOG.md @@ -1,6 +1,8 @@ -# 13.0.0 +# Unreleased - [fixed] Escape quotes, backslashes, and control characters when writing strings to - Crashlytics JSON records. + Crashlytics JSON records for thread names, queue labels, and binary image paths. (#16772) + +# 13.0.0 - [fixed] Safely validate memory reads when executing `DW_OP_deref_size` operations during DWARF stack unwinding (#16550). - [fixed] Fixed an issue casuing a crash while symbolicating stack frames if the binary image path is null. (#16622) diff --git a/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m b/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m index 0c03702ea65..1f1fbd9d48b 100644 --- a/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m +++ b/Crashlytics/Crashlytics/Helpers/FIRCLSFile.m @@ -328,6 +328,8 @@ static void FIRCLSFileWriteStringWithSuffix(FIRCLSFile* file, const char* string, size_t length, char suffix) { + // Signal and Mach exception handlers use this path for thread names and queue labels. + // Keep it async-signal-safe: no allocation, Objective-C calls, or locks. bool needsEscaping = false; for (size_t i = 0; i < length; ++i) { const unsigned char character = (unsigned char)string[i]; diff --git a/Crashlytics/UnitTests/FIRCLSFileTests.m b/Crashlytics/UnitTests/FIRCLSFileTests.m index 3e1e83787df..2fdc1b5fb0b 100644 --- a/Crashlytics/UnitTests/FIRCLSFileTests.m +++ b/Crashlytics/UnitTests/FIRCLSFileTests.m @@ -155,6 +155,44 @@ - (void)testEscapesJSONStrings { [self jsonEscapingWithFile:&_bufferedFile filePath:self.bufferedPath buffered:YES]; } +- (void)testEscapesArrayStringsAcrossBufferFlush { + [self arrayStringEscapingWithFile:&_unbufferedFile filePath:self.unbufferedPath buffered:NO]; + [self arrayStringEscapingWithFile:&_bufferedFile filePath:self.bufferedPath buffered:YES]; +} + +- (void)arrayStringEscapingWithFile:(FIRCLSFile *)file + filePath:(NSString *)filePath + buffered:(BOOL)buffered { + char value[1537]; + const char pattern[] = {'t', '"', '\\', '\n', '\t', 1}; + for (size_t i = 0; i < sizeof(value) - 1; ++i) { + value[i] = pattern[i % sizeof(pattern)]; + } + value[sizeof(value) - 1] = 0; + NSString *expected = [NSString stringWithUTF8String:value]; + + FIRCLSFileWriteSectionStart(file, "thread_names"); + FIRCLSFileWriteArrayStart(file); + FIRCLSFileWriteArrayEntryString(file, value); + FIRCLSFileWriteArrayEntryString(file, "after flush"); + FIRCLSFileWriteArrayEnd(file); + FIRCLSFileWriteSectionEnd(file); + if (buffered) { + XCTAssertGreaterThan([NSData dataWithContentsOfFile:filePath].length, (NSUInteger)0, + @"The long escaped string must flush the 1000-byte buffer"); + FIRCLSFileFlushWriteBuffer(file); + } + NSError *error = nil; + NSData *data = [NSData dataWithContentsOfFile:filePath]; + XCTAssertNotNil(data); + if (!data) { + return; + } + NSDictionary *root = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + XCTAssertNotNil(root, @"Escaped array JSON should parse, got error %@", error); + XCTAssertEqualObjects(root[@"thread_names"], (@[ expected, @"after flush" ])); +} + - (void)jsonEscapingWithFile:(FIRCLSFile *)file filePath:(NSString *)filePath buffered:(BOOL)buffered {