From 7507674022c6e1be3c3ead54d01cb400ab566b71 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 6 Mar 2026 10:52:56 +0100 Subject: [PATCH 1/4] fix(crashlytics,android): fix an issue with deobfuscating flavored builds --- .../crashlytics/ElfBuildIdReader.java | 213 ++++++++++++++++++ .../FlutterFirebaseCrashlyticsPlugin.java | 19 +- 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java new file mode 100644 index 000000000000..b9e27116e974 --- /dev/null +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -0,0 +1,213 @@ +// Copyright 2024 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.firebase.crashlytics; + +import android.content.Context; +import android.util.Log; +import java.io.File; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Reads the ELF build ID from libapp.so at runtime. + * + *

The Firebase CLI's {@code crashlytics:symbols:upload} command uses the ELF build ID (from the + * {@code .note.gnu.build-id} section) when uploading symbols. To ensure Crashlytics can match crash + * reports to uploaded symbols, the plugin must report the same ELF build ID rather than the Dart + * VM's internal snapshot build ID (which may differ, especially for AAB + flavor builds). + */ +final class ElfBuildIdReader { + + private static final String TAG = "FLTFirebaseCrashlytics"; + + private static final byte[] ELF_MAGIC = {0x7f, 'E', 'L', 'F'}; + private static final int ELFCLASS64 = 2; + private static final int PT_NOTE = 4; + private static final int NT_GNU_BUILD_ID = 3; + private static final String GNU_NOTE_NAME = "GNU"; + + private ElfBuildIdReader() {} + + /** + * Reads the ELF build ID from libapp.so in the app's native library directory. + * + * @return the build ID as a lowercase hex string, or {@code null} if it cannot be read. + */ + static String readBuildId(Context context) { + try { + String nativeLibDir = context.getApplicationInfo().nativeLibraryDir; + File libApp = new File(nativeLibDir, "libapp.so"); + if (!libApp.exists()) { + return null; + } + return readBuildIdFromElf(libApp); + } catch (Exception e) { + Log.d(TAG, "Could not read ELF build ID from libapp.so", e); + return null; + } + } + + private static String readBuildIdFromElf(File elfFile) throws Exception { + try (RandomAccessFile raf = new RandomAccessFile(elfFile, "r")) { + // Verify ELF magic bytes. + byte[] magic = new byte[4]; + raf.readFully(magic); + for (int i = 0; i < 4; i++) { + if (magic[i] != ELF_MAGIC[i]) { + return null; + } + } + + int elfClass = raf.read(); // 1 = 32-bit, 2 = 64-bit + boolean is64 = elfClass == ELFCLASS64; + + int dataEncoding = raf.read(); // 1 = little-endian, 2 = big-endian + ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + + if (is64) { + return readBuildIdFromElf64(raf, order); + } else { + return readBuildIdFromElf32(raf, order); + } + } + } + + private static String readBuildIdFromElf64(RandomAccessFile raf, ByteOrder order) + throws Exception { + // e_phoff is at offset 32 in the 64-bit ELF header. + raf.seek(32); + long phoff = readLong(raf, order); + + // e_phentsize is at offset 54, e_phnum at offset 56. + raf.seek(54); + int phentsize = readUnsignedShort(raf, order); + int phnum = readUnsignedShort(raf, order); + + for (int i = 0; i < phnum; i++) { + long phdr = phoff + (long) i * phentsize; + raf.seek(phdr); + int type = readInt(raf, order); + if (type == PT_NOTE) { + // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. + raf.seek(phdr + 8); + long noteOffset = readLong(raf, order); + raf.seek(phdr + 32); + long noteSize = readLong(raf, order); + + String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String readBuildIdFromElf32(RandomAccessFile raf, ByteOrder order) + throws Exception { + // e_phoff is at offset 28 in the 32-bit ELF header. + raf.seek(28); + long phoff = readInt(raf, order) & 0xFFFFFFFFL; + + // e_phentsize is at offset 42, e_phnum at offset 44. + raf.seek(42); + int phentsize = readUnsignedShort(raf, order); + int phnum = readUnsignedShort(raf, order); + + for (int i = 0; i < phnum; i++) { + long phdr = phoff + (long) i * phentsize; + raf.seek(phdr); + int type = readInt(raf, order); + if (type == PT_NOTE) { + // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. + raf.seek(phdr + 4); + long noteOffset = readInt(raf, order) & 0xFFFFFFFFL; + raf.seek(phdr + 16); + long noteSize = readInt(raf, order) & 0xFFFFFFFFL; + + String buildId = findGnuBuildId(raf, noteOffset, noteSize, order); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + /** + * Searches a PT_NOTE segment for the GNU build ID note. + * + *

Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to + * 4) + */ + private static String findGnuBuildId( + RandomAccessFile raf, long offset, long size, ByteOrder order) throws Exception { + long end = offset + size; + long pos = offset; + + while (pos + 12 <= end) { + raf.seek(pos); + int namesz = readInt(raf, order); + int descsz = readInt(raf, order); + int type = readInt(raf, order); + + if (namesz < 0 || descsz < 0 || namesz > 256) { + break; + } + + int nameAligned = align4(namesz); + long descPos = pos + 12 + nameAligned; + + if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { + byte[] nameBytes = new byte[namesz]; + raf.readFully(nameBytes); + // Name is null-terminated. + String name = + namesz > 0 ? new String(nameBytes, 0, Math.max(0, namesz - 1), "US-ASCII") : ""; + + if (GNU_NOTE_NAME.equals(name) && descsz > 0) { + raf.seek(descPos); + byte[] desc = new byte[descsz]; + raf.readFully(desc); + return bytesToHex(desc); + } + } + + pos = descPos + align4(descsz); + } + return null; + } + + private static int align4(int value) { + return (value + 3) & ~3; + } + + private static int readInt(RandomAccessFile raf, ByteOrder order) throws Exception { + byte[] buf = new byte[4]; + raf.readFully(buf); + return ByteBuffer.wrap(buf).order(order).getInt(); + } + + private static long readLong(RandomAccessFile raf, ByteOrder order) throws Exception { + byte[] buf = new byte[8]; + raf.readFully(buf); + return ByteBuffer.wrap(buf).order(order).getLong(); + } + + private static int readUnsignedShort(RandomAccessFile raf, ByteOrder order) throws Exception { + byte[] buf = new byte[2]; + raf.readFully(buf); + return ByteBuffer.wrap(buf).order(order).getShort() & 0xFFFF; + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } +} diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java index 18a55bd9f530..0ad27c015091 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/FlutterFirebaseCrashlyticsPlugin.java @@ -40,6 +40,13 @@ public class FlutterFirebaseCrashlyticsPlugin private MethodChannel channel; private EventChannel testEventChannel; private EventChannel.EventSink testEventSink; + private Context applicationContext; + + // Cached ELF build ID read from libapp.so at startup. This is the build ID that the + // firebase-crashlytics-buildtools JAR extracts from .symbols files during upload, so using + // it ensures crash reports match uploaded symbols (even when the Dart VM's internal snapshot + // build ID differs, which happens with AAB + flavor + obfuscation builds). + private String elfBuildId; private static final String FIREBASE_CRASHLYTICS_COLLECTION_ENABLED = "firebase_crashlytics_collection_enabled"; @@ -56,6 +63,8 @@ private void initInstance(BinaryMessenger messenger) { @Override public void onAttachedToEngine(FlutterPluginBinding binding) { + applicationContext = binding.getApplicationContext(); + elfBuildId = ElfBuildIdReader.readBuildId(applicationContext); initInstance(binding.getBinaryMessenger()); } @@ -157,14 +166,18 @@ private Task recordError(final Map arguments) { final String information = (String) Objects.requireNonNull(arguments.get(Constants.INFORMATION)); final boolean fatal = (boolean) Objects.requireNonNull(arguments.get(Constants.FATAL)); - final String buildId = + final String dartBuildId = (String) Objects.requireNonNull(arguments.get(Constants.BUILD_ID)); @SuppressWarnings("unchecked") final List loadingUnits = (List) Objects.requireNonNull(arguments.get(Constants.LOADING_UNITS)); - if (buildId.length() > 0) { - FlutterFirebaseCrashlyticsInternal.setFlutterBuildId(buildId); + // Prefer the ELF build ID from libapp.so over the Dart VM's snapshot build ID. + // The firebase-crashlytics-buildtools JAR uses the ELF build ID when uploading + // symbols, so we must report the same ID for Crashlytics to match them. + String effectiveBuildId = elfBuildId != null ? elfBuildId : dartBuildId; + if (effectiveBuildId.length() > 0) { + FlutterFirebaseCrashlyticsInternal.setFlutterBuildId(effectiveBuildId); } FlutterFirebaseCrashlyticsInternal.setLoadingUnits(loadingUnits); From 1d274099382ef1f964357a048d60285ae4d4aa71 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 6 Mar 2026 12:20:07 +0100 Subject: [PATCH 2/4] format --- .../flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java index b9e27116e974..8b1e92113887 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -140,8 +140,7 @@ private static String readBuildIdFromElf32(RandomAccessFile raf, ByteOrder order /** * Searches a PT_NOTE segment for the GNU build ID note. * - *

Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to - * 4) + *

Note format: namesz (4) | descsz (4) | type (4) | name (aligned to 4) | desc (aligned to 4) */ private static String findGnuBuildId( RandomAccessFile raf, long offset, long size, ByteOrder order) throws Exception { From c2de3558ef7010f477a3a21dc18e4b5a6ed96dfc Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 6 Mar 2026 15:43:30 +0100 Subject: [PATCH 3/4] improve ELFBuildIdReader so it handles AAB as well --- .../crashlytics/ElfBuildIdReader.java | 206 +++++++++++++++++- 1 file changed, 195 insertions(+), 11 deletions(-) diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java index 8b1e92113887..774f099f5c02 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -7,9 +7,13 @@ import android.content.Context; import android.util.Log; import java.io.File; +import java.io.InputStream; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; /** * Reads the ELF build ID from libapp.so at runtime. @@ -32,46 +36,226 @@ final class ElfBuildIdReader { private ElfBuildIdReader() {} /** - * Reads the ELF build ID from libapp.so in the app's native library directory. + * Reads the ELF build ID from libapp.so. + * + *

First checks the native library directory (for devices that extract native libs). If not + * found there, reads libapp.so from inside the APK (for devices with extractNativeLibs=false). * * @return the build ID as a lowercase hex string, or {@code null} if it cannot be read. */ static String readBuildId(Context context) { try { + // Try extracted native library first. String nativeLibDir = context.getApplicationInfo().nativeLibraryDir; File libApp = new File(nativeLibDir, "libapp.so"); - if (!libApp.exists()) { - return null; + if (libApp.exists()) { + return readBuildIdFromElf(libApp); } - return readBuildIdFromElf(libApp); + + // Fall back to reading from inside the APK (or split APKs for AAB installs). + return readBuildIdFromApk(context); } catch (Exception e) { Log.d(TAG, "Could not read ELF build ID from libapp.so", e); return null; } } + /** + * Reads the ELF build ID from libapp.so stored inside the APK. On newer Android versions, + * native libraries may not be extracted to the filesystem. + */ + private static String readBuildIdFromApk(Context context) throws Exception { + // Check the base APK first. + String result = readBuildIdFromZip(context.getApplicationInfo().sourceDir); + if (result != null) { + return result; + } + + // For AAB installs, libapp.so is in a split APK (e.g., split_config.arm64_v8a.apk). + String[] splitDirs = context.getApplicationInfo().splitSourceDirs; + if (splitDirs != null) { + for (String splitDir : splitDirs) { + result = readBuildIdFromZip(splitDir); + if (result != null) { + return result; + } + } + } + return null; + } + + private static String readBuildIdFromZip(String apkPath) throws Exception { + try (ZipFile zipFile = new ZipFile(apkPath)) { + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + if (entry.getName().endsWith("/libapp.so")) { + try (InputStream is = zipFile.getInputStream(entry)) { + byte[] elfData = new byte[(int) entry.getSize()]; + int offset = 0; + while (offset < elfData.length) { + int read = is.read(elfData, offset, elfData.length - offset); + if (read < 0) break; + offset += read; + } + return readBuildIdFromBytes(elfData); + } + } + } + } + return null; + } + private static String readBuildIdFromElf(File elfFile) throws Exception { try (RandomAccessFile raf = new RandomAccessFile(elfFile, "r")) { + return readBuildIdFromRaf(raf); + } + } + + private static String readBuildIdFromBytes(byte[] data) { + try { + ByteBuffer buf = ByteBuffer.wrap(data); + // Verify ELF magic bytes. - byte[] magic = new byte[4]; - raf.readFully(magic); for (int i = 0; i < 4; i++) { - if (magic[i] != ELF_MAGIC[i]) { + if (buf.get() != ELF_MAGIC[i]) { return null; } } - int elfClass = raf.read(); // 1 = 32-bit, 2 = 64-bit + int elfClass = buf.get() & 0xFF; // 1 = 32-bit, 2 = 64-bit boolean is64 = elfClass == ELFCLASS64; - int dataEncoding = raf.read(); // 1 = little-endian, 2 = big-endian + int dataEncoding = buf.get() & 0xFF; // 1 = little-endian, 2 = big-endian ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + buf.order(order); if (is64) { - return readBuildIdFromElf64(raf, order); + return readBuildIdFromBuffer64(buf); } else { - return readBuildIdFromElf32(raf, order); + return readBuildIdFromBuffer32(buf); } + } catch (Exception e) { + Log.d(TAG, "Could not parse ELF from APK", e); + return null; + } + } + + private static String readBuildIdFromBuffer64(ByteBuffer buf) { + // e_phoff is at offset 32 in the 64-bit ELF header. + buf.position(32); + long phoff = buf.getLong(); + + // e_phentsize is at offset 54, e_phnum at offset 56. + buf.position(54); + int phentsize = buf.getShort() & 0xFFFF; + int phnum = buf.getShort() & 0xFFFF; + + for (int i = 0; i < phnum; i++) { + int phdr = (int) (phoff + (long) i * phentsize); + buf.position(phdr); + int type = buf.getInt(); + if (type == PT_NOTE) { + // p_offset is at phdr + 8, p_filesz at phdr + 32 for 64-bit. + buf.position(phdr + 8); + long noteOffset = buf.getLong(); + buf.position(phdr + 32); + long noteSize = buf.getLong(); + + String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String readBuildIdFromBuffer32(ByteBuffer buf) { + // e_phoff is at offset 28 in the 32-bit ELF header. + buf.position(28); + long phoff = buf.getInt() & 0xFFFFFFFFL; + + // e_phentsize is at offset 42, e_phnum at offset 44. + buf.position(42); + int phentsize = buf.getShort() & 0xFFFF; + int phnum = buf.getShort() & 0xFFFF; + + for (int i = 0; i < phnum; i++) { + int phdr = (int) (phoff + (long) i * phentsize); + buf.position(phdr); + int type = buf.getInt(); + if (type == PT_NOTE) { + // p_offset is at phdr + 4, p_filesz at phdr + 16 for 32-bit. + buf.position(phdr + 4); + long noteOffset = buf.getInt() & 0xFFFFFFFFL; + buf.position(phdr + 16); + long noteSize = buf.getInt() & 0xFFFFFFFFL; + + String buildId = findGnuBuildIdInBuffer(buf, noteOffset, noteSize); + if (buildId != null) { + return buildId; + } + } + } + return null; + } + + private static String findGnuBuildIdInBuffer(ByteBuffer buf, long offset, long size) { + long end = offset + size; + long pos = offset; + + while (pos + 12 <= end) { + buf.position((int) pos); + int namesz = buf.getInt(); + int descsz = buf.getInt(); + int type = buf.getInt(); + + if (namesz < 0 || descsz < 0 || namesz > 256) { + break; + } + + int nameAligned = align4(namesz); + long descPos = pos + 12 + nameAligned; + + if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { + byte[] nameBytes = new byte[namesz]; + buf.get(nameBytes); + String name = new String(nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII); + + if (GNU_NOTE_NAME.equals(name) && descsz > 0) { + buf.position((int) descPos); + byte[] desc = new byte[descsz]; + buf.get(desc); + return bytesToHex(desc); + } + } + + pos = descPos + align4(descsz); + } + return null; + } + + private static String readBuildIdFromRaf(RandomAccessFile raf) throws Exception { + // Verify ELF magic bytes. + byte[] magic = new byte[4]; + raf.readFully(magic); + for (int i = 0; i < 4; i++) { + if (magic[i] != ELF_MAGIC[i]) { + return null; + } + } + + int elfClass = raf.read(); // 1 = 32-bit, 2 = 64-bit + boolean is64 = elfClass == ELFCLASS64; + + int dataEncoding = raf.read(); // 1 = little-endian, 2 = big-endian + ByteOrder order = dataEncoding == 1 ? ByteOrder.LITTLE_ENDIAN : ByteOrder.BIG_ENDIAN; + + if (is64) { + return readBuildIdFromElf64(raf, order); + } else { + return readBuildIdFromElf32(raf, order); } } From 3b73e5f968f3b31a395a8162583bb45ef52cbdb3 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 6 Mar 2026 15:47:51 +0100 Subject: [PATCH 4/4] format --- .../plugins/firebase/crashlytics/ElfBuildIdReader.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java index 774f099f5c02..0e6b9561168a 100644 --- a/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java +++ b/packages/firebase_crashlytics/firebase_crashlytics/android/src/main/java/io/flutter/plugins/firebase/crashlytics/ElfBuildIdReader.java @@ -61,8 +61,8 @@ static String readBuildId(Context context) { } /** - * Reads the ELF build ID from libapp.so stored inside the APK. On newer Android versions, - * native libraries may not be extracted to the filesystem. + * Reads the ELF build ID from libapp.so stored inside the APK. On newer Android versions, native + * libraries may not be extracted to the filesystem. */ private static String readBuildIdFromApk(Context context) throws Exception { // Check the base APK first. @@ -221,7 +221,9 @@ private static String findGnuBuildIdInBuffer(ByteBuffer buf, long offset, long s if (namesz > 0 && type == NT_GNU_BUILD_ID && descPos + descsz <= end) { byte[] nameBytes = new byte[namesz]; buf.get(nameBytes); - String name = new String(nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII); + String name = + new String( + nameBytes, 0, Math.max(0, namesz - 1), java.nio.charset.StandardCharsets.US_ASCII); if (GNU_NOTE_NAME.equals(name) && descsz > 0) { buf.position((int) descPos);