🐛

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

2022/01/16に公開約12,000字

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として保存します。

エラーの種類によって、スタックトレースの形式が異なりますが、私の確認する限り下記の2パターンに分類できました。

ピリオド(.)で始まり末尾に (.java)を含む形式
. #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)
???で始まり0x0 (null).を含む形式
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

ログインするとコメントできます