ボードゲームをリバースエンジニアリングした
I reverse engineered a board game (wuemeli.com)
要約
筆者は、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氏、協力してくれてありがとう。