Androidアプリケーションのネイティブ層(JNI)を対象としたリバースエンジニアリングと、その検証アルゴリズムの解析手法について解説します。対象は libvalidate.so に実装された Java_com_flareon_flare_ValidateActivity_validate 関数です。IDA Proなどの逆アセンブラを用いた静的解析により、複雑な数学的演算に基づいたフラグ検証ロジックを特定し、復号スクリプトを構築します。
ネイティブ関数の静的解析
対象のJNI関数をC言語の擬似コードとして再構築すると、以下のようになります。入力文字列の長さが46バイト以下であることを確認した後、2バイト(16ビット)ごとに区切って数値変換し、特定のテーブルに対する演算結果を比較しています。
jint JNICALL Java_com_flareon_flare_ValidateActivity_validate(JNIEnv *jni_env, jobject instance, jstring input_jstr) {
const char *user_input = (*jni_env)->GetStringUTFChars(jni_env, input_jstr, NULL);
size_t input_len = strlen(user_input);
if (!user_input || input_len > 46) {
(*jni_env)->ReleaseStringUTFChars(jni_env, input_jstr, user_input);
return (*jni_env)->NewStringUTF(jni_env, "Invalid input.");
}
uint16_t prime_exponents[3476] = {0};
uint32_t target_offsets[23];
memcpy(target_offsets, &OFF_5004, sizeof(target_offsets));
int match_count = 0;
bool is_valid = true;
for (size_t i = 0; i < input_len; i += 2) {
memset(prime_exponents, 0, sizeof(prime_exponents));
uint16_t current_val = 0;
if (user_input[i]) {
current_val = (uint8_t)user_input[i];
if (user_input[i + 1]) {
current_val = (current_val << 8) | (uint8_t)user_input[i + 1];
}
}
for (int p_idx = 0; p_idx < 3476; ++p_idx) {
uint16_t prime = WORD_2214[p_idx];
while (current_val > 1 && (current_val % prime) == 0) {
prime_exponents[p_idx]++;
current_val /= prime;
}
if (current_val <= 1) break;
}
if (memcmp(&target_offsets[match_count * 4], prime_exponents, sizeof(prime_exponents)) == 0) {
match_count++;
} else {
is_valid = false;
}
}
(*jni_env)->ReleaseStringUTFChars(jni_env, input_jstr, user_input);
const char *result_msg = (match_count == 23 && is_valid) ? "That's it!" : "Incorrect.";
return (*jni_env)->NewStringUTF(jni_env, result_msg);
}
アルゴリズムの特定:質因数分解によるハッシュ化
上記のコードから、検証ロジックは以下の手順で構成されていることが読み取れます。
- 入力値の変換: 入力文字列を2文字(16ビット)ごとに結合し、1つの整数値
current_valを生成します。 - 素数テーブルによる除算:
.rodataセクションに配置された3476個の16ビット整数配列(WORD_2214)を参照します。この配列は主に素数で構成されています。 - 指数の記録:
current_valを素数で割り切れる回数(指数)をカウントし、3476要素の配列prime_exponentsに記録します。これは実質的に質因数分解のプロセスです。 - 正解データとの比較: 生成された指数配列を、グローバル変数に格納された23個の正解ターゲット配列と順次比較します。すべてのペアで一致した場合、検証は成功となります。
復号スクリプトの構築
検証ロジックが質因数分解に基づいているため、復号(フラグの特定)は「指数と素数から元の数値を復元する」逆演算を行うことで実現できます。Pythonを用いたソルバーは以下のようになります。
import pathlib
import array
def solve_flareon_android():
so_path = pathlib.Path('libvalidate.so')
file_bytes = so_path.read_bytes()
# ELFのロードアドレスとファイルオフセットの差分を考慮
base_offset = 0x1000
# 23個のターゲット配列へのオフセット(ポインタ)を読み込む
target_offsets_addr = 0x5004 - base_offset
target_offsets = array.array('I')
target_offsets.frombytes(file_bytes[target_offsets_addr:target_offsets_addr + 23 * 4])
# 素数テーブル(3476個の16ビット整数)を読み込む
primes_addr = 0x2214
primes = array.array('H')
primes.frombytes(file_bytes[primes_addr:primes_addr + 3476 * 2])
decoded_chars = []
for offset in target_offsets:
# 各ターゲット配列のファイルオフセットを計算
exp_array_addr = offset - base_offset
exponents = array.array('H')
exponents.frombytes(file_bytes[exp_array_addr:exp_array_addr + 3476 * 2])
# 質因数分解の逆演算(累乗の積)で16ビット値を復元
restored_val = 1
for prime, exp in zip(primes, exponents):
if exp > 0:
restored_val *= (prime ** exp)
# 16ビット値を2つのASCII文字に分割
decoded_chars.append((restored_val >> 8) & 0xFF)
decoded_chars.append(restored_val & 0xFF)
# 結果を文字列として出力
flag = ''.join(chr(c) for c in decoded_chars if c != 0)
print(flag)
if __name__ == '__main__':
solve_flareon_android()
このスクリプトでは、ELFファイルのロードアドレスとファイルオフセットの差分を考慮してメモリマップを解析しています。ターゲット配列のオフセットと素数テーブルを読み込み、累乗の積を計算することで元の16ビット値を復元し、最終的に2つのASCII文字に分割してフラグを出力します。