🐛

難読化されたDartコードのCrashlyticsログをデコードする

2022/01/16に公開

Dartコードの難読化

ソースコードの難読化はアプリバイナリを加工し読みにくくすることで、リバースエンジニアリングの難易度をあげることができます。Flutter でもビルド時に難読化するコマンドが用意されており、コンパイルされた Dart コードの関数名やクラス名を隠すことができます。2022年1月現在、サポートされているプラットフォームは Android/iOS/macOS のみです。

Flutter’s code obfuscation, when supported, works only on a release build.

また、ドキュメント記載の通りリリースビルドのみで機能します。
https://docs.flutter.dev/deployment/obfuscate

Crashlyticsにおけるデコードの問題

本来、難読化したコードをデコードする場合は、生のスタックトレースとデバッグファイルを用意すれば flutter symbolize コマンドを使うことで、デコードが可能です。

私は Firebase Crashlytics(以下 Crashlytics)を使ってアプリのクラッシュレポート(Non-Fatal なエラーも含む)を送信していますが、コンパイル後のコードが難読化されているため、Crashlytics へ送信されるレポートも難読化後のものとなります。

Crashlytics ログからソースコードの該当箇所を特定→修正するサイクルが一般的だと思いますが、難読化しているためにクラス名やファイル名が伏せられてしまい、どこが原因で発生したレポートなのか特定することが困難という問題があります。例えば、下記のような Crashlytics ログが得られますが、これだけでは良くわかりません。

Non-fatal Exception: FlutterError
0  ???  0x0 (null).  #00 abs 0 _kDartIsolateSnapshotInstructions+0x3e277b
1  ???  0x0 (null).  #01 abs 0 _kDartIsolateSnapshotInstructions+0x59f67b

※以前は難読化後の文字情報が失われて手動で元のシンボルを復元できない問題がありお手上げ状態でしたが、2.0.1 にて下記 PR がマージされたことにより、デコードできるようになりました。
https://github.com/FirebaseExtended/flutterfire/pull/4407

本記事では、Crashlytics に送信される難読化された Dart コードからデバッグファイルを使ってデコードするまでを執筆しました(半分備忘録です)。

スタックトレース/デバッグファイルを準備する

Reading an obfuscated stack trace の通りにコマンド実行するため、まずは必要な2つのファイルを準備します。

1. スタックトレースファイルの用意

まず、デコードしたいスタックトレースを手元に用意します。Firebase コンソールから Crashlytics のログ (. #00 ・(.java)などが付いているもの) をそのままコピー&ペーストし errors.txt として保存します。

Android または iOS のログによってスタックトレースの形式が異なります。

Android の場合
. #00 abs 0 virt 000000000071f347 _kDartIsolateSnapshotInstructions+0x3e28a7 (.java)
. #01 abs 0 virt 00000000008dc247 _kDartIsolateSnapshotInstructions+0x59f7a7 (.java)
. #02 abs 0 virt 000000000071f317 _kDartIsolateSnapshotInstructions+0x3e2877 (.java)
. #03 abs 0 virt 000000000071f417 _kDartIsolateSnapshotInstructions+0x3e2977 (.java)
. #04 abs 0 virt 000000000065224f _kDartIsolateSnapshotInstructions+0x3157af (.java)
. #05 abs 0 virt 000000000081161b _kDartIsolateSnapshotInstructions+0x4d4b7b (.java)
. #06 abs 0 virt 00000000008113eb _kDartIsolateSnapshotInstructions+0x4d494b (.java)
. #07 abs 0 virt 00000000005ce2ff _kDartIsolateSnapshotInstructions+0x29185f (.java)
. #08 abs 0 virt 00000000005ce29b _kDartIsolateSnapshotInstructions+0x2917fb (.java)
. #09 abs 0 virt 00000000003b1673 _kDartIsolateSnapshotInstructions+0x74bd3 (.java)
. #10 abs 0 virt 00000000003a4a0f _kDartIsolateSnapshotInstructions+0x67f6f (.java)
. #11 abs 0 virt 00000000003a43b3 _kDartIsolateSnapshotInstructions+0x67913 (.java)
. #12 abs 0 virt 00000000003a437b _kDartIsolateSnapshotInstructions+0x678db (.java)
. #13 abs 0 virt 00000000003b4f2f _kDartIsolateSnapshotInstructions+0x7848f (.java)
. #14 abs 0 virt 00000000003b4e6b _kDartIsolateSnapshotInstructions+0x783cb (.java)
. #15 abs 0 virt 00000000003b4bc3 _kDartIsolateSnapshotInstructions+0x78123 (.java)
. #16 abs 0 virt 00000000003b4a93 _kDartIsolateSnapshotInstructions+0x77ff3 (.java)
. #17 abs 0 virt 000000000034593f _kDartIsolateSnapshotInstructions+0x8e9f (.java)
. #18 abs 0 virt 0000000000345a2b _kDartIsolateSnapshotInstructions+0x8f8b (.java)
. #19 abs 0 virt 000000000086d99b _kDartIsolateSnapshotInstructions+0x530efb (.java)
. #20 abs 0 virt 000000000086eb4b _kDartIsolateSnapshotInstructions+0x5320ab (.java)
. #21 abs 0 virt 00000000003535ab _kDartIsolateSnapshotInstructions+0x16b0b (.java)
. #22 abs 0 virt 0000000000357d7b _kDartIsolateSnapshotInstructions+0x1b2db (.java)
. #23 abs 0 virt 0000000000357d37 _kDartIsolateSnapshotInstructions+0x1b297 (.java)
. #24 abs 0 virt 0000000000357df3 _kDartIsolateSnapshotInstructions+0x1b353 (.java)
iOS の場合
0  ???                            0x0 (null).    #00 abs 0 _kDartIsolateSnapshotInstructions+0x1f0913
1  ???                            0x0 (null).    #01 abs 0 _kDartIsolateSnapshotInstructions+0x3edb83
2  ???                            0x0 (null).    #02 abs 0 _kDartIsolateSnapshotInstructions+0x3eacf3
3  ???                            0x0 (null).    #03 abs 0 _kDartIsolateSnapshotInstructions+0x2ce2eb
4  ???                            0x0 (null).    #04 abs 0 _kDartIsolateSnapshotInstructions+0x2cee47
5  ???                            0x0 (null).    #05 abs 0 _kDartIsolateSnapshotInstructions+0x2bf5f3
6  ???                            0x0 (null).    #06 abs 0 _kDartIsolateSnapshotInstructions+0x2bf3af
7  ???                            0x0 (null).    #07 abs 0 _kDartIsolateSnapshotInstructions+0x5ecfb
8  ???                            0x0 (null).    #08 abs 0 _kDartIsolateSnapshotInstructions+0x5e953
9  ???                            0x0 (null).    #09 abs 0 _kDartIsolateSnapshotInstructions+0x5e8a3
10 ???                            0x0 (null).    #10 abs 0 _kDartIsolateSnapshotInstructions+0x799eb
11 ???                            0x0 (null).    #11 abs 0 _kDartIsolateSnapshotInstructions+0x79db3
12 ???                            0x0 (null).    #12 abs 0 _kDartIsolateSnapshotInstructions+0x79ccf
13 ???                            0x0 (null).    #13 abs 0 _kDartIsolateSnapshotInstructions+0x81b3
14 ???                            0x0 (null).    #14 abs 0 _kDartIsolateSnapshotInstructions+0x8437
15 ???                            0x0 (null).    #15 abs 0 _kDartIsolateSnapshotInstructions+0x531c5b
16 ???                            0x0 (null).    #16 abs 0 _kDartIsolateSnapshotInstructions+0x532103
17 ???                            0x0 (null).    #17 abs 0 _kDartIsolateSnapshotInstructions+0x166bf
18 ???                            0x0 (null).    #18 abs 0 _kDartIsolateSnapshotInstructions+0x1b5b3
19 ???                            0x0 (null).    #19 abs 0 _kDartIsolateSnapshotInstructions+0x1b537

2. デバッグファイルの用意

難読化のビルド時に --split-debug-info フラグを付けることでデバッグファイルを出力できます。今回デコードしたい Crashlytics レポートの発生ビルド番号に対応するデバッグファイルを手元に用意します(ビルド時にこのデバッグファイルを保存し忘れていた場合はデコードできません)。CPU 別にデバッグファイルが出力されているはずです。
https://docs.flutter.dev/deployment/obfuscate#obfuscating-your-app

flutter build apk --obfuscate --split-debug-info=/<project-name>/<directory>

Crashlyticsログをデコードする

公式ドキュメント通り、flutter symbolize コマンドを叩くわけですが、このままだとCrashlyticsログはデコードされません。 該当イシューにある下記コメントの手順に従うと、難読化されたスタックトレースからデコードして、難読化前のスタックトレースを得ることができます。

https://github.com/FirebaseExtended/flutterfire/issues/2644#issuecomment-746217634

要点は2点です。

  • 各行ピリオド (.)(.java) などの余計な要素を取り除き各行 # で始まるように整形する
  • 各行の頭に4つの空白を追加する

これらを適当なエディタで整形した後に、下記コマンドを叩くとデコードが成功します。

flutter symbolize -i <stack trace file> -d /out/android/app.android-arm64.symbols

スクリプトを使ってデコードする

上記の整形を都度エディターで行うのでも良いですが、何度か繰り返すと煩わしく感じるはずですのでスクリプトを用意して実行しています。スクリプトは Dart で書くことができる grinder | Dart Package を使用しています(ソースコードは最下部に掲載)。
※grinder のセットアップや実行方法については今回は割愛しますが下記が参考になると思います。
https://qiita.com/0maru/items/b134c5ee319e3cac2a99

整形前のスタックトレースファイル(ピリオド(.)(.java) などが付いているもの)とデバッグファイルをお好きなディレクトリに格納した後、下記コマンドを実行します。
(私は /symbolize というディレクトリを作って Git 管理対象外としています)

flutter pub run grinder symbolize-obfuscated-stack-trace --os=android

※コマンド実行時、必要に応じて実行時に CPU アーキテクチャを指定します(未指定の場合は arm64が選択されるようにしています)。
※Crashlytics ログを出力している端末と異なる CPU アーキテクチャを指定した場合にはデコードがうまく行われません(厳密には、一見デコードが成功しているように見えますが内容が異なることを確認しています)。

symbolizeコマンドのデコード結果

デコード結果がコンソールログに出力されます。一部デコード結果を伏せていますが、実際にはソースコードの該当行が出力されてるため、該当箇所が分かるようになりました。これで Crashlytics 対応が捗りますね。

デコード結果

※一部伏せています。

#0      AsyncValueX|get#value.<anonymous closure> (package:riverpod/src/common.dart:206:21)
#1      AsyncError._map (package:riverpod/src/common.dart:391:17)
#2      AsyncValueX|get#value (package:riverpod/src/common.dart:203:12)
#3      xxxxxxx
#4      _ConsumerState.build (package:flutter_riverpod/src/consumer.dart:371:19)
#5      StatefulElement.build (package:flutter/src/widgets/framework.dart:4782:27)
#6      ConsumerStatefulElement.build (package:flutter_riverpod/src/consumer.dart:431:20)
#7      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4665:15)
#8      StatefulElement.performRebuild (package:flutter/src/widgets/framework.dart:4840:11)
#9      Element.rebuild (package:flutter/src/widgets/framework.dart:4355:5)
#10     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2620:33)
#11     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:882:21)
#12     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:319:5)
#13     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart)
#14     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1143:15)
#15     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1080:9)
#16     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:996:5)
#17     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart)
#18     _rootRun (dart:async/zone.dart:1428:13)
#19     _rootRun (dart:async/zone.dart)
#20     _CustomZone.run (dart:async/zone.dart:1328:19)
#21     _CustomZone.runGuarded (dart:async/zone.dart:1236:7)
#22     _invoke (dart:ui/hooks.dart:166:10)
#23     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:270:5)
#24     _drawFrame (dart:ui/hooks.dart:129:31)
#25     _drawFrame (dart:ui/hooks.dart)

下記、実行に利用したソースコードとなります。

grind.dart
// 難読化されたCrashlyticsレポートをsymbolizeするタスクです。
('symbolize-obfuscated-stack-trace')
void symbolizeObfuscatedStackTrace() {
  final args = context.invocation.arguments;
  final os = args.getOption('os') ?? 'android';
  final cpu = args.getOption('cpu') ?? 'arm64';
  const pathPrefix = './tool/symbolize';
  final stackTraceRawFile = File('$pathPrefix/errors.txt');
  if (!stackTraceRawFile.existsSync()) {
    log('Could not find stack trace file named by `errors.txt`.');
    return;
  }
  final lines = stackTraceRawFile.readAsLinesSync();

  // StackTraceをsymbolizeできるよう整形
  // ref. https://github.com/FirebaseExtended/flutterfire/issues/2644#issuecomment-746217634
  // - 各行の`#`前を取り除く
  // - `(.java)`をが存在する場合は取り除く
  // - 各行の頭に4つの空白を追加
  final formattedStackTrace = lines.map(
    (line) {
      final l = line.split('#').last.replaceAll('(.java)', '').trim();
      return '    #$l';
    },
  ).toList();

  final buffer = StringBuffer();
  formattedStackTrace.forEach(buffer.writeln);

  final formattedStackTraceFile = File('$pathPrefix/errors_formatted.txt')
    ..writeAsStringSync(
      buffer.toString(),
    )
    ..createSync();

  final dartSymbols = File('$pathPrefix/app.$os-$cpu.symbols');
  if (!dartSymbols.existsSync()) {
    log('Could not dart symbols file.');
    return;
  }
  final result = Process.runSync(
    'flutter',
    [
      'symbolize',
      '-i',
      formattedStackTraceFile.path,
      '-d',
      dartSymbols.path,
    ],
  );
  log(result.stdout.toString());
}

最後に

こちらの記事のように Crashlytics レポートが送信さたことを契機に GitHub Issue を作成するスクリプトが既にあるのでこの辺りを使いつつ、本記事のデコード処理(flutter コマンドが使えない場合はフォーマットまででも)もまとめて自動化しておくと捗りそうです。
https://qiita.com/koishi/items/84e44a54f4ab75bd23ea

https://github.com/kevalpatel2106/github-issue-cloud-function

上記を使わずとも、中身は Cloud Functions の Crashlytics トリガーを使って実装されているだけですので、自分好みにカスタマイズして作るのでも良さそうですね。

functions.crashlytics.issue().onNew(async (issue) => {
  // do something...
});

https://medium.com/google-cloud/understanding-firebase-cloud-functions-and-triggers-️-85a8fe89be3c#:~:text=Firebase Crashlytics triggers,issue for the first time

参考

Discussion