HN 日本語サマリー

← 一覧へ戻る
プログラミング

ボードゲームをリバースエンジニアリングした

I reverse engineered a board game (wuemeli.com)

74 pointsby wuemeli32 コメント

要約

筆者は、QRコードをスキャンしてSpotifyやApple Musicで曲を再生するボードゲーム「Hitster」のAndroidアプリをリバースエンジニアリングしました。当初、アプリ内のJSONデータがAES暗号化されていると推測し、コードを解析して復号プロセスを特定しましたが、秘密鍵の導出に手間取りました。最終的に、暗号化されていないJSONファイルが公開URLで直接入手可能であることが判明し、18時間の作業が無駄になったという体験談です。

全文翻訳

ボードゲームをリバースエンジニアリングした方法

先日、プレゼントとして「Hitster」というゲームをもらいました。これは、曲を特定し、リリース年、アーティスト名などを推測するボードゲームです。各カードにはQRコードがあり、Hitsterアプリでスキャンすると、SpotifyやApple Music経由で音楽が再生されます。

残念ながら、このアプリはApple MusicまたはSpotifyのサブスクリプションが必要です。この記事では、Androidアプリをリバースエンジニアリングし、彼らの非常に、非常にずさんなデータ隠蔽の試みを回避するまでの道のりをたどります。

パート0: 仕組み

まず、ゲームの仕組みを理解する必要があります。

各カードの表面には、アーティスト名またはバンド名、曲のリリース年、曲のタイトルが記載されています。裏面にはQRコードがあります。それをスキャンすると、URLにアクセスし、そのパスにはゲームの地域(私の場合は「DE」)とカードIDの2つの情報が含まれています。その後、アプリ内またはSpotify経由で音楽が再生されます。

これは、Card ID ⇒ Spotify Track のマッピングが存在することを意味します。また、地域ごとに複数のカードバージョンがあるため、カードIDだけでは曲を特定できません。カードID 1はイギリスでは「Wet Dream」かもしれませんが、ドイツでは「Tage wie diese」かもしれません。

[画像: card.png]

[画像: qr_code.png]

パート1: Androidアプリ

怪しげなAndroid APKダウンロードサイトからアプリのAPKを入手した後、jadxを使用してJavaコードを逆コンパイルしました。

[画像: source_code]

しばらく探していると、res/rawの下にいくつかのJSONファイルが見つかりました。そのうちの1つはgameset_database_4.jsonという名前で、暗号化されているように見えました。

フォーマットを識別するツールを実行したところ、Base64だと返ってきました。これはエンコーディングなので、まずデコードしました。結果はまだ読めず、AESか何かで暗号化されていると推測しました。

以前にAndroidアプリをリバースエンジニアリングしたことはありませんでした。そこで、グローバルにAESを検索しました。するとどうでしょう? k43.javaというファイルがあり、そこに復号プロセス全体が記述されていました。

public static String f(String str, String str2) throws o21 {
    str.getClass();
    str2.getClass();
    try {
        byte[] bArrDecode = Base64.decode(str, 0);
        bArrDecode.getClass();
        p(bArrDecode);
        return o(
            or.m(bArrDecode, 0, 12),
            bArrDecode,
            m(bArrDecode.length - 28, str2)
        );
    } catch (o21 e) {
        throw e;
    } catch (Exception e2) {
        Napier.e$default(
            Napier.INSTANCE,
            e2,
            (String) null,
            new s11(24),
            2,
            (Object) null
        );
        throw new o21(pv0.o("Decryption failed: ", e2.getMessage()), e2);
    }
}

public static SecretKeySpec m(int i, String str)
    throws NoSuchAlgorithmException {
    String str2 = i + str;
    MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
    byte[] bytes = str2.getBytes(yj0.a);
    bytes.getClass();
    return new SecretKeySpec(messageDigest.digest(bytes), "AES");
}

public static String o(
    byte[] bArr,
    byte[] bArr2,
    SecretKeySpec secretKeySpec
)
    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(
        2,
        secretKeySpec,
        new GCMParameterSpec(SemanticState.Focused, bArr)
    );
    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);
    bArrDoFinal.getClass();
    return new String(bArrDoFinal, yj0.a);
}

では、これをステップバイステップで見ていきましょう。

関数fは2つの文字列を入力として受け取ります。最初のstrにはBase64エンコードされたデータが含まれており、それをデコードします。

byte[] bArrDecode = Base64.decode(str, 0);
bArrDecode.getClass();
p(bArrDecode);

次に、デコードされたデータが少なくとも28バイトであることを確認します。

public static void p(byte[] bArr) throws o21 {
    if (bArr.length < 28) {
        throw new o21(
            gg3.g(
                bArr.length,
                "Encrypted data too small: ",
                " bytes (minimum: 28 bytes)"
            ),
            null
        );
    }
}

次に、o関数に多くのデータを渡します。この関数が復号の最終段階を処理します。

return o(
    or.m(bArrDecode, 0, 12),
    bArrDecode,
    m(bArrDecode.length - 28, str2)
);

最初の引数は、デコードされたデータの最初の12バイトです。これがIV、つまりnonceです。2番目の引数は、デコードされたバイト配列全体です。

3番目の引数は興味深いところです。これは、全体の長さから28を引いたものをstr2と共にm関数に渡します。この28バイトは、12バイトのnonceと16バイトの認証タグに相当します。

m関数内では、残りの長さが文字列に変換され、str2と結合されます。その結合された文字列がSHA-256でハッシュ化され、AESキーが生成されます。

したがって、str2はAESキーそのものではありません。それはキーを導出するために使用される秘密の文字列であり、まだ見つかっていません。

最後に、oが実際の復号を行います。

public static String o(
    byte[] bArr,
    byte[] bArr2,
    SecretKeySpec secretKeySpec
)
    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
    Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
    cipher.init(
        2,
        secretKeySpec,
        new GCMParameterSpec(SemanticState.Focused, bArr)
    );
    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);
    bArrDoFinal.getClass();
    return new String(bArrDoFinal, yj0.a);
}

導出されたキーとnonceでAES-GCMを設定し、最初の12バイト以降のデータを復号します。

これで復号プロセスは解明されました。あとは秘密の文字列を見つけるだけです。

パート2: 復号キーの発見

Rust Language CommunityサーバーのDiscord通話中に、誰かが長さ32バイトのバイト配列を探すべきだと指摘しました。

最初にbyte[32]を検索しましたが、有用なものは見つかりませんでした。次にこのパターンを試しました。

grep -P 'byte[]\s*\w+\s*=\s*\{' | awk -F',' 'NF==32'

これによりm17.javaが見つかり、これらのクールな配列が含まれていました。

public static final byte[] a = {117, -67, 13, -80, 43, 2, 93, 61, 9, 71, 61, -85, 12, -119, -123, 58, -13, 30, -119, -59, -30, 60, 3, 56, -34, -80, -28, -124, 88, -124, 1, 68};
public static final byte[] b = {45, -28, 93, -4, 29, 49, 31, 87, 102, 16, 119, -31, 71, -54, -10, 66, -72, 40, -80, -73, -86, 14, 77, 86, -85, -55, -121, -55, 46, -73, 98, 29};

次に、これらをどのように有用なものに変えるかを考えました。少し実験した後、誰かが2つの配列をXORすることを提案しました。結果のバイトをテキストとして解釈すると、次のようになりました。

XYPL63BjoWJJKCsxK69rH2NnuycMv3cY

この文字列を使用して、Rustで小さな復号器を作成しました。

use aes_gcm::
    Aes256Gcm,
    Nonce,
    aead::{Aead, AeadCore, Generate, Key, KeyInit},
;
use base64::Engine;
use sha2::{Digest, Sha256};

const STRING_2: &str = "XYPL63BjoWJJKCsxK69rH2NnuycMv3cY";

fn main() {
    let input = std::fs::read_to_string(
        "/home/<redacted>/Downloads/HITSTER_4.0.3_APKPure.xapk.out/HITSTER_4.0.3_apkcombo.com/resources/nl.jumbo.hitster.apk/res/raw/locales_v4.json",
    );

    let data = base64::engine::general_purpose::STANDARD
        .decode(input.unwrap())
        .unwrap();

    let iv = &data[..12];
    let ct = &data[12..];

    let mut hash = Sha256::new();
    hash.update((data.len() - 28).to_string().as_bytes());
    hash.update(STRING_2);
    let key = hash.finalize();

    let cipher = Aes256Gcm::new(&key);
    let plaintext = cipher.decrypt(Nonce::from_slice(iv), ct).unwrap();

    std::fs::write("test.txt", plaintext).unwrap();
}

これにより、約31,000行のマッピング(カードIDからApple MusicおよびSpotifyトラック、ISRC、複数の地域にわたる)を含む、この素晴らしいtest.txtファイルが得られました。

その後、コードをもう少し調べたところ、これらすべてが実際には必要なかったことがわかりました。

最初にHTTPS URLを検索していれば、これにたどり着いたはずです。

https://hitster.jumboplay.com/hitster-assets/

そして少し想像力を働かせて試してみました。

https://hitster.jumboplay.com/hitster-assets/gameset_database.json

そこにすべてがありました。暗号化なしで。

結局、実際には本当の秘密ではなかったファイルに、約18時間も費やしてしまいました。

クレジット:

  • Rust Language DiscordコミュニティのMr.Corrupt氏、協力してくれてありがとう。