
최근 선물로 보드게임 ‘Hitster’를 받았다. 노래를 알아맞히고 발매 연도와 가수나 밴드 같은 정보를 추측하는 게임이다. 각 카드의 QR 코드를 Hitster 앱으로 스캔하면 Spotify나 Apple Music에서 음악이 재생된다.
안타깝게도 앱을 이용하려면 Apple Music이나 Spotify 구독이 필요하다. 그래서 안드로이드 앱을 역공학해 데이터를 숨기려던 허술한 시도를 파헤쳐 보기로 했다.
게임과 앱의 작동 방식
먼저 게임이 어떻게 작동하는지 알아야 한다.
카드 앞면에는 가수나 밴드 이름, 노래가 나온 연도, 곡 제목이 적혀 있다. 뒷면에는 QR 코드가 있다. 코드를 스캔하면 게임 지역(내 경우 DE)과 카드 ID가 경로에 들어간 URL로 이동한다. 이후 앱이나 Spotify에서 음악이 재생된다.
즉 카드 ID와 Spotify 트랙을 연결하는 대응 정보가 있다. 지역마다 카드 버전도 여러 개라 카드 ID만으로는 노래를 식별할 수 없다. 카드 ID 1은 영국에서는 “Wet Dream”일 수 있지만 독일에서는 “Tage wie diese”일 수 있다.
앱에서 암호화 방식 찾기
수상한 안드로이드 APK 다운로드 사이트에서 앱의 APK를 받은 뒤 jadx로 디컴파일된 자바 코드를 살펴봤다.
조금 찾아보니 res/raw 아래에서 JSON 파일 몇 개를 발견했다. 그중 gameset_database_4.json이라는 파일은 암호화된 것처럼 보였다.
파일 형식을 확인하는 도구를 돌려 보니 Base64로 나왔다. Base64는 인코딩이므로 먼저 디코딩했다. 결과는 여전히 읽을 수 없었고, AES 같은 방식으로 암호화된 것이라고 추측했다.
안드로이드 앱을 역공학한 적은 없어서 전체 코드에서 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는 문자열 두 개를 입력받는다. 첫 번째 문자열 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) 또는 논스로 쓰인다. 두 번째 인수는 디코딩된 바이트 배열 전체다.
세 번째 인수에서 핵심이 드러난다. 전체 길이에서 28을 뺀 값과 str2를 함수 m에 넘긴다. 28바이트는 12바이트 논스와 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); }
파생된 키와 논스로 AES-GCM을 설정한 다음 처음 12바이트 뒤에 있는 데이터를 복호화한다.
복호화 방식은 알아냈다. 이제 비밀 문자열만 찾으면 된다.
복호화 문자열 찾기
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};
두 배열을 어떻게 쓸지 살펴보다가 누군가 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(); }
그 결과 test.txt 파일이 나왔다. 약 3만 줄에 걸쳐 여러 지역의 카드 ID와 Apple Music·Spotify 트랙, ISRC가 대응돼 있었다.
그러고 나서 코드를 조금 더 살펴보다가 이 과정이 사실 필요하지 않았다는 것을 알아냈다.
처음부터 HTTPS URL을 검색했다면 다음 주소를 찾았을 것이다.
https://hitster.jumboplay.com/hitster-assets/
조금 더 추측해 다음 주소를 입력해 보았다.
https://hitster.jumboplay.com/hitster-assets/gameset_database.json
전체 데이터가 있었다. 암호화도 되어 있지 않았다.
결국 실제 비밀도 아닌 파일을 찾느라 약 18시간을 썼다.
Rust Language Discord 커뮤니티에서 도움을 준 Mr.Corrupt에게 감사한다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요