1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「旅行管理アプリ」の正体は違法アニメ配信だった ― APK解析と OSINT で偽装アプリを追跡した話

1
Last updated at Posted at 2026-05-10

「旅行管理アプリ」の正体は違法アニメ配信だった ― APK解析と OSINT で偽装アプリを追跡した話

はじめに

ある日 YouTube を見ていたら、見覚えのないアニメ配信アプリの広告が流れてきた。

「最新アニメ見放題!」

                                         🤔

怪しい。絶対違法だゾ...
気になって Google Play で開いてみたら、そこには 「旅行準備チェックリスト」 と書かれていた。(??)
スクリーンショット 2026-05-11 050143.png

完全に旅行アプリの面構え

広告とストア表記が完全に一致していない。これは何かおかしい。インストールして起動してみると、当たり前のようにアニメ配信 UI が立ち上がった。

252730.jpg
アカンよ

つまりこのアプリは Google Play 審査では旅行アプリの顔をして、配信後にユーザーに対しては違法ストリーミングサービスとして機能している
でも、説明欄とスクリーンショットでは完全に旅行アプリ🙄
気になる。

そこで APK を解析した。さらに気になって背後の運営者まで OSINT で辿ってみたら、8 アプリ・4 開発者アカウント・複数の偽装ブランド にまたがる組織的なクラスターが浮かび上がってきた。

なお本記事では、運営者の特定につながる情報の一部(メールアドレス、内部アクティベーションコードの具体値、住所の番地など)は伏せている。技術的にどう動くかを伝えるのが目的で、運営側に対策の手がかりを渡すのが目的ではない。


第1部:APK 解析編

環境

ツール 用途
jadx DEX → Java 逆コンパイル
apktool リソース・Manifest 復号
apksigner 署名証明書確認
Python + cryptography 自作 API クライアント(実通信検証用)
ADB + Android 実機 データクリア・機内モード起動の動作検証

APK は Google Play の APK Mirror 系サイトから合法的に取得(再配布はしない)。

Manifest を見る

apktool d で展開して AndroidManifest.xml を確認する。Launcher Activity は com.example.app.pages.RxxjActivity(クラス名は実名から改変)。アクティビティ名はすべて 4文字の変な難読化が施されている。

<activity android:name="com.example.app.pages.RxxjActivity"
          android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>
</activity>

ところが、Manifest に登録されている Activity の中に、テーマだけ全く別系統で Theme_Android_xui という命名の塊が見つかる。

<activity android:name="com.example.app.xui.TraMainActivity"
          android:theme="@style/Theme_Android_xui" />
<activity android:name="com.example.app.xui.settings.SettingsActivity"
          android:theme="@style/Theme_Android_xui" />

xui パッケージだけ命名規則が浮いている。明らかに別のチームが書いた、別系統のアプリが同じ APK に同梱されている。

隠された分岐

RxxjActivity を jadx で開くと、r() メソッドにこんな分岐が出てくる(変数名は難読化を解除済み)。

public final void r() {
    // ... 初期化処理 ...
    if (j.c(j.f10300u)) {  // MMKV から boolean を読む
        startActivity(new Intent(this, W31qActivity.class));   // ストリーミング UI
        finish();
        return;
    }
    Intent intent2 = new Intent(this, TraMainActivity.class);  // 旅行アプリ UI
    startActivity(intent2);
    finish();
}

j.c(str) は MMKV の getBoolean(str, false) を返すラッパー。デフォルトは false。つまり MMKV 上のあるフラグが true ならストリーミング UI、false なら旅行 UI を出す

このフラグのキー名は AES/ECB で難読化された文字列。復号すると KEY_LOCAL_OPS だった。

xui パッケージの中身

com.example.app.xui 以下を覗くと、完全に機能する旅行準備アプリが実装されている。
偽装されたUIはどうやらこれらしい🤔(たぶん)

xui/TraMainActivity.java          ─ BottomNavigationView + NavController
xui/checklist/ChecklistFragment   ─ 荷物チェックリスト
xui/timeline/TimelineFragment     ─ 旅行スケジュール
xui/reminders/RemindersFragment   ─ リマインダー
xui/budget/BudgetFragment         ─ 予算管理
xui/journal/JournalFragment       ─ 旅行日記
xui/settings/SettingsActivity     ─ 設定画面

5タブの BottomNavigation、RecyclerView でカード追加、AlertDialog で項目編集、SharedPreferences でローカル保存 ― 完全に動く。Google Play 審査でこのアプリを動かせば、誰がどう見ても旅行アプリにしか見えない。

ちなみに SharedPreferences のファイル名は yeah_spfだった。草

サーバーがフラグをセットする

ではいつ KEY_LOCAL_OPStrue になるのか。/84/ という数字エンドポイントへの POST レスポンスを処理するコードを追うと、こうなっていた。

case 9:
    XkawBean xkawBean = (XkawBean) obj;
    if (xkawBean != null) {
        // ... 各種設定値を SharedPrefs に書き込む ...
        j.d(j.f10302w, xkawBean.getSword());
        j.d(j.f10301v, xkawBean.getResolution());

        if (!j.c(KEY_LOCAL_OPS)) {
            // resolution の値が特定のセンチネル値と一致するなら true
            j.i(KEY_LOCAL_OPS, TextUtils.equals(
                xkawBean.getResolution(),
                EXPECTED_SENTINEL  // ハードコードされた "5桁*3桁" 形式の文字列
            ));
        }
    }
    ((RxxjActivity) obj2).r();  // フラグに応じて UI 切替

つまりサーバーが resolution フィールドに特定の値を返すと、デバイスは「正規ユーザー」と認定されてストリーミング UI が解放される。サーバー側で IP・User-Agent・端末識別子などを見て、「明らかに Google の審査ロボット」「明らかに研究者」「未認定の新規端末」には別の値を返せば、その端末はずっと旅行アプリのままだ。

もう一つの隠しアンロック ― チェックリストの検索欄

さらに巧妙なのは、旅行アプリ側にも隠しアンロック機構があったことだ。チェックリストのフラグメントには検索用テキスト欄があるが、その onEditorAction を読むと:

// 入力テキストを通常の検索 API に投げつつ...
hashMap.put("sig", trim);
api.search(hashMap).subscribe(...);

// 同時に、隠れた条件分岐
if (TextUtils.equals(SharedPrefs[KEY_RESOLUTION], EXPECTED_SENTINEL_2) &&
    TextUtils.equals(SharedPrefs[KEY_SWORD], trim)) {
    MMKV[KEY_LOCAL_OPS] = true;
    restartActivity(RxxjActivity.class);
}

二段階の照合になっている:

  1. SharedPrefs に格納された「アクティベーションコード」が特定のセンチネル値と一致
  2. SharedPrefs に格納された「秘密パスワード」と、ユーザーがいま入力したテキストが一致

両方成立すると VIP フラグが true になり、再起動でストリーミング UI が現れる。サーバーは認定デバイスにのみセンチネル値とパスワードを配布し、配布されたパスワード自体は別チャンネル(Telegram 等と推測)でユーザーに告知する仕組みと考えられる。

リクエスト/レスポンスの暗号化

サーバー通信は OkHttp Interceptor で全リクエストが暗号化される。フォーム全フィールドを JSON 化し、AES/ECB/PKCS7 で暗号化し、Base64 化して v という単一フィールドに格納。さらに wws=1 というインジケータを添える。

# Python で再現したリクエスト構築(実コードから抜粋)
key = "ri39fu0du3jkr0kw"  # APK 内のハードコード文字列を復号して得た鍵

def build_request(fields: dict) -> dict:
    json_body = json.dumps(fields, separators=(',', ':')).encode()
    cipher = AES.new(key.encode(), AES.MODE_ECB)
    padded = pad(json_body, 16, 'pkcs7')
    encrypted = cipher.encrypt(padded)
    return {
        "v": base64.b64encode(encrypted).decode(),
        "wws": "1"
    }

レスポンスは別の鍵で AES/ECB/NoPadding。ここに微妙な落とし穴があった。

// レスポンス復号鍵 = MD5(app_id) の hex string の先頭16文字
String key = MD5(app_id).hex().substring(0, 16);

app_idg2.a.f31440a という静的フィールドに、これも AES で難読化されて埋まっていた。復号すると "456" という3文字。MD5("456").hex()[:16] = 250cf8b51c773f3f がレスポンス復号鍵になる。

NoPadding の方の復号で躓いた。Java の String.trim()コードポイント ≤ 32 の全文字(タブ・改行に加えて 0x0F・0x10 のような制御文字)を削るが、Python の str.strip() は空白系のみ。素直に書くと末尾のゴミが残って JSON パースに失敗する。

def java_trim(s: str) -> str:
    """Java String.trim() を正確に再現"""
    return s.strip(''.join(chr(i) for i in range(33)))

これでようやく自前の Python クライアントから API を叩いて応答を読めるようになった。

機内モードで偽装 UI を強制召喚する

ここまでわかれば、偽装 UI を出す方法は自明だ。MMKV の KEY_LOCAL_OPS のデフォルトは false なので、初回起動時にサーバーから認定値を受け取らせなければ良い

adb shell pm clear com.example.app   # MMKV を含む全データ消去
# 端末を機内モードに
# アプリを起動

(別にadbからじゃなくてもいいけど、かっこいいでしょ👍️)
サーバー通信が失敗 → MMKV に書き込みが行われない → r() が読む KEY_LOCAL_OPSfalse のまま → 旅行アプリの方が起動する。

252731.jpg
完全に旅行アプリ。広告でアニメ配信を謳っていた同じバイナリとは思えない

これが Google Play の審査担当者が見ている画面だ。


第2部:OSINT 編

ここまでで「これは違法配信を旅行アプリで偽装したアプリだ」とわかった。じゃあ誰が運営しているのか? APK の中と公開情報から辿ってみる。

開発者メールが APK 内に平文で埋まっていた

g2.a クラスには app_id の他にもいくつかの定数が埋まっている。順番に AES で復号していくと、4つ目の定数に開発者メールアドレスが平文で入っていた

g2.a.f31440a → "456"             (app_id)
g2.a.b       → "euranime456_"    (アプリ識別子)
g2.a.f31441c → "<author>@outlook.com"   ← 開発者メール(マスク)
g2.a.e       → "ri39fu0du3jkr0kw"       (リクエスト暗号鍵)

Google Play 上のアプリ詳細ページにも同じメールアドレスが「サポートメール」として記載されている。APK 内ハードコード = ストア記載のサポートメール = 同一。これは確定情報。

プライバシーポリシー所有者の追跡

ストア掲載のプライバシーポリシーは https://sites.google.com/view/<author>/privacypolicy という URL でホストされている。Google Sites のサブパスにアカウント名が出るタイプ。

このサイトのタイトルは現在のアプリ名ではなく、過去に削除された別アプリの名前で固定されていた。つまりこの Google アカウントは過去から継続して保有されており、新アプリのプライバシーポリシーを毎回作り直さずに使い回している。

そして、別の開発者アカウントから出ている全く別ブランドのアプリのプライバシーポリシー URL を確認すると、これも同じ Google Sites サブパスを指していた

複数のブランド・複数の Google Play 開発者アカウントが、ひとつの Google アカウントによって管理されていることが Google 側のサブドメイン構造から間接的に証明された。

同一暗号鍵が別アプリにも

別ブランド「AniMochi」(実在のパッケージ名は伏せる)の APK も解析してみると、リクエスト暗号鍵が 完全に同じ ri39fu0du3jkr0kw だった。レスポンス暗号鍵の derive ロジックも同じ。API の数字エンドポイント体系も同じ。レスポンス Bean の構造も同じ。

これは偶然では起こらない。同じソースコードからビルドされた、同じテンプレート由来のアプリだ。

シグネチャを使って Google Play を片っ端から検索すれば、同一ソースから派生した「兄弟アプリ」を網羅できる。今回は最終的に 8 アプリ、4 開発者アカウント が同一クラスターと判定できた。

Google Play サブメタデータでの言語・地域シグネチャ

プライバシーポリシー本文に列挙されているサードパーティ SDK が興味深かった。Facebook、AdMob、AppLovin など海外標準のものに混ざって、Umeng(友盟)と JPush(極光推送) が含まれている。

SDK 提供元 海外採用
Umeng(友盟) アリババ系 ほぼ中国系開発者のみ
JPush(極光推送) 極光(JIGUANG) 中国系

Umeng と JPush は中国国内のモバイル分析・プッシュ通知サービスで、海外向けアプリで採用しているのはほぼ中国系開発者に限定される。

物理拠点

Google Play 開発者ページに記載された住所は 中国・北京市内の複合商業施設(区まで言及・番地は伏せる)。Google Play のポリシーで事業所住所は実在を要求されるので、運営者は何らかの形でこの住所と関連がある。

中国本土からは違法配信が起きやすい地理的特性(CDN・ホスティングが安価、Google Play からの収益化が国境を越えやすい、削除されても別アカウントで再登録しやすい等)も整合する。

本名のヒント

最古の開発者アカウント(2020年〜)の連絡先メールが、中国姓 + 個人名らしきピンインで構成されたパターン + 誕生年らしき4桁 という形をしていた。

これだけでは個人特定はできないが、「中国姓を持つ個人または小規模チーム」 という profile は他の証拠(北京住所・中国系 SDK・Outlook メールの並列保有)と整合する。完全な実名特定は LE / Apple / Google の内部情報照合がないと無理。

Apple 側はすでに対応済み

iOS App Store にも一時期、同じブランド名のアプリが出ていた。Apple の iTunes Lookup API(https://itunes.apple.com/lookup?id=<id>)を US/PL/MA など複数地域で叩いてみると:

{ "resultCount": 0, "results": [] }

全地域で resultCount: 0既に App Store から完全削除されている。Apple は対応済み、対する Google Play 側はまだ放置されている、という非対称が明らかになった。

ブランドの進化

整理すると、このクラスターの活動はこういう時系列になる。

2020          最初の開発者アカウント設立、AnimeTV 系アプリを公開
2021-04       第2の開発者アカウント設立、別ブランドで展開
2022-10       第3の開発者アカウント設立
2022-11       初期アプリが Google Play 違反で削除(1M+ DL を喪失)
2023-01       アプリ名のリブランド開始(旧名のパッケージ名に新ブランド名)
2023-03       第4の傀儡アカウント設立、本体と分離した別ブランドで運営
2024-08       別の主力アプリが削除
2025-09-13    最大アプリ(1M+ DL)が削除
2025-09-19    削除6日後に新アプリ即時投入
2026-現在     5アプリが現行稼働中

Google Play は個別アプリを削除しても、運営側は6日で次のアプリを公開してくる。アプリ単位の削除では止まらない、運営者単位での恒久対応が必要ということがこのタイムラインから読み取れる。


おわりに

アプリ1本から始めて、ここまでが個人で追跡できた。

技術的にまとめるとポイントは3つ:

  1. 動的に UI を切り替える偽装アーキテクチャ ― サーバー応答で旅行アプリと違法配信 UI を切り替える二重構造。Google Play 審査では旅行アプリしか起動しない
  2. 暗号化通信による解析回避 ― 全 API トラフィックを AES/ECB で暗号化。動的解析ツールでパケットを見ても JSON の中身は読めない
  3. クラスター運営での削除耐性 ― 複数アカウント・複数ブランドを並列保有し、削除ごとに別ブランドで再登場

OSINT 側でわかったことは:

  • ストア記載のサポートメールが APK 内ハードコード と一致
  • プライバシーポリシー URL が 複数ブランドで同一 Google アカウント を共有
  • 中国系 SDK の採用、北京住所、ピンイン形式のメール ― これらすべてが整合
  • iOS は Apple が削除済み、Android は放置 ― 対応の非対称

通報先としては:

  • Google Play Trust & Safety:個別アプリではなくクラスター単位の運営者 BAN を要請(AdSense Payee 情報照合の依頼)
  • AdMob Trust & Safety:違法配信への広告配信停止(Publisher ID 横断照合)
  • CODA(コンテンツ海外流通促進機構):日本のアニメ被害者団体としての横断的削除要請
  • AWS Trust & Safety / GoDaddy abuse:ホスティング・ドメイン側の停止

個人でここまで追跡できるとはいえ、最終的にこういう運営を止めるには プラットフォーム側の構造的対応(決済情報での同一性検出、自動的な「ストア説明と APK 機能の乖離」検出)が必要だと思う。今回の偽装 UI は本気でやれば自動検出できるはずだ。(個人的にアニメとかの海賊版は好かん!)


注意:本記事で扱った具体的アプリ名・パッケージ名・運営者識別情報の一部は伏せている。再現したい場合は、自分で広告を見つけたアプリで、自己責任で、合法的な範囲で(自分の所有端末・自分が APK を取得したアプリのみ)試してほしい。

※調査対象アプリの著作物(動画・画像・字幕等)は一切取得していない。

(追記)本件はCODAに報告済み:2026/5/11

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?