ProGuardでデバッグログを"完全"に削除することの難しさ

こんにちは、Progate の岩崎です。新卒で Google に入社して約9年間ソフトウェアエンジニアとして働いた後に Niantic へ転職、今年の1月から Progate のコンテンツチームで働いています。

Google で「ProGuardチョットデキル」エンジニアとして頑張っていた時期があるのですが、Progate でもその経験を活かそうということで社内の Android エンジニアと ProGuard について話をする機会を設けました。

ただミーティング中に「Progate と Proguard って名前似てるな」と実にしょうも無いことを考えていたので、その罪を償うために ProGuard についての話をTechBlogに寄稿させていただきます。

そのデバッグメッセージ本当に消えてる?

多くの Android エンジニアにとって ProGuard はデバッグログをリリースビルドから消すためのツールですよね。現在は R8 にそのポジションを奪われてしまったかもしれませんが、ProGuard の設定ファイルは今でも現役です。

しかし、リリースビルドで logcat からメッセージが出なくなったことを確認しただけで安心していませんか? 消えるから安心といって機密情報をデバッグログに出力していませんか?

きっとあなたの proguard-rules.pro はこんな感じですよね。

# リリースビルドにはデバッグログを含めない。
-assumenosideeffects public class android.util.Log {
    public static *** v(...);
    public static *** d(...);
    public static *** i(...);
}

で、消えると思ってこんなコードを書いてしまったことは無いですか。。。?

Log.d("PROGATE", String.format("%sの耳はロバの耳!", kingName));

王様の悪口に限らず、社内サイトのURLやテストアカウントのパスワード、未発表のプロダクトの情報などデバッグログには外部に漏れたら困る情報が入っていることが多々あると思います。

しかし、結論から言うと上記の ProGuard ルールでは logcat 上にデバッグログは出力されませんが、"%sの耳はロバの耳!"という文字列自体はリリースビルドのバイナリに残りますし

String.format("%sの耳はロバの耳!", kingName)

の処理も残っています。

確かめてみよう

使用したソフトウェアのバージョンは

  • Android Studio: 4.1.2

  • Build tool version: 30.0.3

  • Kotlin: 1.5.0-M1

です。

R8は開発中の3.0.27-devでも試しました。いくつかの最適化が追加されているようですが以下で触れている多くの部分について変更点は見られませんでした。

まずは新規の Android プロジェクトを作って適当にログを出力するコードを書きます。

public void TestProguard() {
    String kingName = "The king";

    // デバッグメッセージ。リリースビルドでは logcat に表示されない。
    Log.d(TAG, String.format("PROGATE: %s has donkey ears!", kingName));

    // エラーメッセージはリリースビルドにも残る。
    Log.e(TAG, "PROGATE: This is an error message.");
}

デフォルトの proguard-rules.pro を始めの例のように書き換えたうえで Release バイナリをビルドしてみましょう。logcat にはエラーメッセージだけ出力されて、デバッグメッセージは出力されていないようです。

E/Progate: PROGATE: This is an error message.

「めでたしめでたし」でしょうか? 生成されたバイナリを逆アセンブルしてから、ログメッセージに含まれる文字列で grep してみましょう。

$ dexdump -d $APK | grep 'PROGATE'
0de4ba: 1a00 290d                              |0011: const-string v0, "PROGATE: %s has donkey years!" // string@0d29
0de4c8: 1a00 2c0d                              |0018: const-string v0, "PROGATE: This is an error message." // string@0d2c

エラーメッセージが残っているのは想定通りですが、消えていてほしいログメッセージも残ってますね。なぜでしょうか?

周辺のコードを読んでみよう

何が起こっているかを理解するのに手っ取り早い方法は逆アセンブルしてみることです。baksmali を使って MainActivity の中を見てみましょう

$ baksmali d $APK
$ less out/com/progate/myproguardapplication/MainActivity.smali

該当の部分を見てみましょう

const/4 p1, 0x1

new-array p1, p1, [Ljava/lang/Object;

const/4 v0, 0x0

const-string v1, "The king"

aput-object v1, p1, v0

const-string v0, "PROGATE: %s has donkey years!"

.line 3

invoke-static {v0, p1}, Ljava/lang/String;->format(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;

return-void

smali の文法に詳しくない方の為に Java っぽく書くとこんな感じになります。

Object[] varargs = new Object[1];
varargs[0] = "The king";

String.format("PROGATE: %s has donkey ears!", varargs);

return;

Log.d の呼び出し自体は無くなっていますが、ログメッセージの作成自体は行われていることがわかりますね。

理由を考えてみよう

なぜこの様なことが起こるのでしょうか? それは ProGuard/R8 が String.format に限らずメソッドの呼び出しに副作用が存在する可能性を考慮しているからです。

副作用が存在する場合、関数の返り値が使われていなくても、メソッド呼び出しを削除することでアプリケーションの挙動が変わってしまいますよね。最適化の結果として挙動が変わるようなことがあっては困るので ProGuard(R8) はこの様な動きをするようになっています。

String.format に副作用なんて無い!という声が聞こえてきそうですが

  • Java のランタイムライブラリ次第
  • ProGuard/R8 の最適化の多くは標準ライブラリの実装に依存していない
  • 渡されたオブジェクトの toString が副作用を持っているかもしれない

等の理由で String.format の呼び出しを無条件に消すのは安全ではありません。

他の文字列処理はどうなっている?

せっかくなので他の例もいくつかみてみましょう。

// 残る
private void StringConcat(String arg) {
    Log.d(TAG, "PROGATE: String.concat ".concat(arg));
}

// 残る
private void StringFormat(String arg) {
    Log.d(TAG, String.format("PROGATE: String.format %s", arg));
}

// 消える
private void StringPlusString(String arg) {
    Log.d(TAG, "PROGATE: String + String" + arg);
}

// 残る
private void StringPlusObject(Object obj) {
    Log.d(TAG, "PROGATE: String + Object " + obj);
}

Kotlinの文字列テンプレートもいくつか試してみました。

// 残る
private fun stringTemplateAppendString(str: String) {
    Log.d(TAG, "PROGATE: Kotlin stringTemplate String $str")
}

// 残る
private fun stringTemplateAppendInt(num: Int) {
    Log.d(TAG, "PROGATE: Kotlin stringTemplate Int $num")
}

// 残る
private fun stringTemplateAppendObject(obj: Object) {
    Log.d(TAG, "PROGATE: Kotlin stringTemplate Object $obj")
}

// 消える
private fun stringTemplateInsertInt(num: Int) {
    Log.d(TAG, "PROGATE: Kotlin stringTemplate insert Int ($num) between strings.")
}

// 消える
private fun stringTemplateMixed(num: Int, str: String) {
    Log.d(TAG, "PROGATE: Kotlin stringTemplate Mixed $num $str")
}

多くの文字列同士の連結や Kotlin の文字列テンプレートは消えていますが、一部のものは残っています。少しだけ詳しくみてみましょう。

String.concat や String.format

すでに述べたように今の所 StringBuilder 以外の標準ライブラリ向けの最適化は行われていないようです。存在するか分からない String.concat の副作用を懸念して String.concat の呼び出しはデバックメッセージと共にそのまま残されます。

文字列連結や文字列テンプレート (StringBuilder)

多くの文字列連結やKotlinの文字列テンプレートは StringBuilder を使った実装にコンパイルされます。結果だけ見るとこれらの場合の多くはうまく最適化されているようです。

ProGuard/R8 の最適化の多くは標準ライブラリの実装に依存していない

と上の方に書きましたが、実はR8には StringBuilder向けの最適化が色々と入っていて、安全に削除できるかどうかの判断を多くの場合適切にやってくれます。

例えばStringBuilder.toString() の結果が使われていない場合は最適化でその StringBuilder のインスタンス作成やメソッド呼び出しが削除されます。

StringBuilder の例外

String と Object を連結しているケースで最適化が行われていないです。

これは軽く見た感じでは R8 のバグのようです。nosideeffect が指定されているメソッドの引数に渡っている StringBuilder が未使用と判断されていないようです。

Kotlin の文字列テンプレートで末尾に値を追加している場合

文字列の末尾に文字列以外のオブジェクトを追加する場合最適化が行われないことがあるようです。

始めにこれらの連結は Kotlin コンパイラによって Intrinsics.stringPlus(String, Object) へとコンパイルされるようです。多くの場合 stringPlus は String.concat 等と同様に何の最適化もされずにおしまいです。

ただし、stringPlus の呼び出しがインライン化された場合はこれの実装が StringBuilder であるため最適化が行われることもあるようです。

解決案

R8 の事情はある程度理解できましたが、結局どうするのが良いのでしょうか? いくつか現実的な対策を紹介します。

まずはテストしてみましょう

デバッグメッセージの内リリースバイナリから消えていて欲しい物をいくつかピックアップしてディスアセンブルしたコードに含まれていないか確認してみましょう。

dexdump -d $APK | egrep '(donkey|debug-password|secret)'

もしリリースバイナリに残っているデバッグメッセージを見つけたらその周辺のコードを読んで原因を考えてみましょう。

String.concat, String.format, Intrinsics.stringPlus等だった場合

ProGuard の設定でこれらのメソッドに副作用が無いと指定すると、返り値が使われていない場合これらの呼び出しは削除されます。

-assumenosideeffects class java.lang.String {
    public static java.lang.String format(...);
    public java.lang.String concat(java.lang.String);
}

-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    public static java.lang.String stringPlus(java.lang.String, java.lang.Object);
}

ただし String.format や Intrinsics.stringPlus の場合と同じように引数で渡された Object の toString を呼び出すメソッドについては注意が必要です。アプリケーションのどこかに副作用のある toStringを持ったクラスがあると意図しない動作をする可能性があります。

また StringBuilder.append のような副作用が存在するメソッドについてはこの方法は使えません。

StringBuilder

StringBuilder については R8のバグが直るのを待つしかありません。R8 には現在進行形で StringBuilder に対する最適化が追加されています。開発が盛んに行われている内に問題を報告することで次の Android S 用のリリースで修正される可能性が上がると思います。何かうまく動かないケースを見つけたらバグレポートしてみましょう。

もし 未だに R8 ではなく本家の ProGuard を使っている場合は assumenoexternalsideeffects を使うことで最適化の対象にすることができます。

ProGuard のマニュアルを参考に以下のオプションを ProGuard のルールに追加してみましょう。

-assumenoexternalsideeffects class java.lang.StringBuilder {
    public java.lang.StringBuilder();
    public java.lang.StringBuilder(int);
    public java.lang.StringBuilder(java.lang.String);
    public java.lang.StringBuilder append(java.lang.Object);
    public java.lang.StringBuilder append(java.lang.String);
    public java.lang.StringBuilder append(java.lang.StringBuffer);
    public java.lang.StringBuilder append(char[]);
    public java.lang.StringBuilder append(char[], int, int);
    public java.lang.StringBuilder append(boolean);
    public java.lang.StringBuilder append(char);
    public java.lang.StringBuilder append(int);
    public java.lang.StringBuilder append(long);
    public java.lang.StringBuilder append(float);
    public java.lang.StringBuilder append(double);
    public java.lang.String toString();
}

-assumenoexternalreturnvalues public final class java.lang.StringBuilder {
    public java.lang.StringBuilder append(java.lang.Object);
    public java.lang.StringBuilder append(java.lang.String);
    public java.lang.StringBuilder append(java.lang.StringBuffer);
    public java.lang.StringBuilder append(char[]);
    public java.lang.StringBuilder append(char[], int, int);
    public java.lang.StringBuilder append(boolean);
    public java.lang.StringBuilder append(char);
    public java.lang.StringBuilder append(int);
    public java.lang.StringBuilder append(long);
    public java.lang.StringBuilder append(float);
    public java.lang.StringBuilder append(double);
}

このオプションは StringBuilder.append の副作用が StringBuilder のインスタンスの内部以外には存在しないことを指定するものです。つまりその後の toString 等の呼び出しの返り値が使われていないなら安全に append が消せるということですね。

ただしそこそこ新しい ProGuard でしか対応していないうえに R8 はこのオプションを無視するので気をつけてください。

またそもそもこのオプションを使っている人がかなり少ないと思うので思わぬバグに苦労することになるかもしれないです。 (2021/3/16時点で、ぐぐっても84 件 しかヒットしませんでした…)

まとめ

  • Progate の名前は ProGuard に似ている
  • R8 にどんどんバグレポートしよう
  • ProGuard を正しく使うのは難しい
  • Progate は絶賛採用中です!