후킹으로 잡는 하드코딩 키
앱이 데이터를 암호화하는 그 순간, 키와 IV는 반드시 메모리를 통과한다. Android의
Cipher.init과 iOS의CCCrypt한 지점만 잡으면 된다.
Platform Android / iOS · Tool Frida · jadx · Target AES/CBC
모바일 앱 진단을 하다 보면 "이 앱은 통신 데이터를 암호화해서 보낸다"는 벽을 자주 만난다.
패킷을 떠봐야 전부 암호문이니 그다음이 막힌다. 그런데 암호화에는 반드시 키가 필요하고, 그 키는 어디선가 암호화 함수로 전달된다. 바로 그 전달 지점을 후킹하면, 앱이 아무리 암호문을 잘 숨겨도 키 자체를 손에 넣을 수 있다.
이 글은 어느 금융 앱을 진단하면서 Android와 iOS 양쪽에서 하드코딩된 AES 키와 고정 IV를 찾아낸 과정을 정리한 것이다.
핵심은 한 문장으로 요약된다.
암호화 함수에 키가 들어가는 순간을 가로챈다.
01 · 원리 — 왜 Cipher를 후킹하는가
Android에서 대칭·비대칭 암복호화는 거의 전부 javax.crypto.Cipher를 거친다.
AES든 RSA든, 어떤 라이브러리를 쓰든 최종적으로 이 클래스로 모인다. 그래서 여기 한 곳만 잡으면 앱이 무엇을 어떻게 암호화하는지 한눈에 관찰할 수 있다.
특히 주목할 메서드는 세 개다.
getInstance(transformation)— 어떤 알고리즘·모드·패딩을 쓰는지 알려준다.AES/ECB나 약한 패딩 같은 설계 취약점이 여기서 바로 드러난다.init(opmode, key, ...)— 키와 IV가 들어오는 핵심 지점. 이 글의 주인공이다.doFinal(input)— 입력 평문과 출력 암호문. 키 추출이 막혀도 평문/암호문은 여기서 관찰된다.
진단에서 노리는 건 init이다.
암호화 객체는 쓰기 직전 반드시 init으로 키를 주입받는다. 그 순간 전달된 Key 객체에서 getEncoded()로 raw 바이트를, 파라미터 스펙에서 IV를 꺼내면 된다.
02 · Android — Cipher.init 후킹
Frida로 Cipher.init의 모든 오버로드를 감싸고, 인자로 들어온 Key와 IV 스펙을 hex로 출력하도록 짰다.
init은 오버로드가 여러 개라 전부 걸어야 누락이 없다.
Java.perform(function () {
var Cipher = Java.use('javax.crypto.Cipher');
var GCMSpec = Java.use('javax.crypto.spec.GCMParameterSpec');
var IvSpec = Java.use('javax.crypto.spec.IvParameterSpec');
// Key 객체 → raw 바이트를 hex로
function hex(bytes) {
if (bytes === null) return '(null)';
var s = '';
for (var i = 0; i < bytes.length; i++)
s += ('0' + (bytes[i] & 0xff).toString(16)).slice(-2);
return s;
}
// 모든 init 오버로드를 감싼다
Cipher.init.overloads.forEach(function (ov) {
ov.implementation = function () {
console.log('\n[Cipher.init] transform=' + this.getAlgorithm());
for (var i = 1; i < arguments.length; i++) {
var arg = arguments[i];
if (arg === null) continue;
// 1) Key
try {
if (arg.getEncoded && arg.getAlgorithm) {
console.log(' key(hex)=' + hex(arg.getEncoded()));
continue;
}
} catch (e) {}
// 2) IV (CBC 등)
try {
if (IvSpec.class.isInstance(arg))
console.log(' IV (hex)=' + hex(Java.cast(arg, IvSpec).getIV()));
} catch (e) {}
}
var ret = ov.apply(this, arguments);
// 스펙 없이 Cipher가 내부 생성한 IV까지 회수
try {
var auto = this.getIV();
if (auto !== null) console.log(' IV(cipher.getIV())=' + hex(auto));
} catch (e) {}
return ret;
};
});
console.log('[*] Cipher.init hook 설치 완료');
});
스크립트를 로드한 상태에서 앱의 인증번호 입력 기능을 수행했다.
입력 전에는 로그가 조용하다가, 입력하는 순간 후킹이 트리거되며 값이 쏟아졌다. 이 선후관계 자체가 "이 암호화가 인증 처리에 쓰인다"는 증거가 된다.
[Cipher.init] transform=AES/CBC/PKCS5Padding
key(hex)=4558414d504c454b4559313233343536
IV (hex)=4558414d504c45495631323334353637
[Cipher.init] transform=AES/CBC/PKCS5Padding
key(hex)=4558414d504c454b4559313233343536
IV (hex)=4558414d504c45495631323334353637
[Cipher.init] transform=RSA/ECB/PKCS1Padding
key(hex)=(null) <-- Keystore (non-exportable)
[Cipher.init] transform=AES/GCM/NoPadding
key(hex)=(null) <-- Keystore (non-exportable)
IV(cipher.getIV())=aea87f75a29dd95ce23122d7구조가 한눈에 보인다.
신규 데이터는 Keystore 기반 GCM으로 처리해 키가 (null)로 나온다(비추출, 양호). RSA도 마찬가지다.
그런데 CBC 경로만 키가 raw로 그대로 찍혔다. 그리고 그 값을 바이트로 읽는 순간, 결정적인 게 드러난다.
03 · 판별 — hex를 ASCII로 읽어본다
제대로 생성된 키는 SecureRandom이 뽑은 난수라서, 바이트 값이 0x00~0xFF 전 범위에 아무렇게나 흩어진다.
ASCII로 읽으면 깨진 문자와 제어문자가 뒤섞여 절대 단어가 되지 않는다. 그런데 이 키는 달랐다.
KEY 45 58 41 4d 50 4c 45 4b 45 59 31 32 33 34 35 36
E X A M P L E K E Y 1 2 3 4 5 6
→ "EXAMPLEKEY123456"
IV 45 58 41 4d 50 4c 45 49 56 31 32 33 34 35 36 37
E X A M P L E I V 1 2 3 4 5 6 7
→ "EXAMPLEIV1234567"16바이트가 전부 숫자·영대문자(0x30~0x5A) 범위에만 깔끔하게 들어가 있다. 우연일 확률은 사실상 0이다.
이런 패턴이 보이면, 개발자가 소스에 문자열 상수로 박아뒀다는 신호다. (예시 값으로 치환했으며, 실제 키는 위와 동일한 구조로 나왔다)
셸에서 한 줄로 바로 확인된다.
$ echo 4558414d504c454b4559313233343536 | xxd -r -p; echo
EXAMPLEKEY123456
바이트를 글자로 읽었더니 비밀번호처럼 생긴 문자열이 튀어나왔다.
이것이 하드코딩을 눈으로 바로 잡아내는 가장 빠른 신호다.
04 · 교차검증 — jadx로 소스를 확인한다
동적 분석으로 확보한 값이 진짜 소스에 박힌 상수인지, jadx로 디컴파일해 교차검증했다.
암호화 유틸리티 클래스에서 바이트 단위로 일치하는 배열 선언을 찾았다.
// com.example.bank.util.CryptoUtil
// Key → "EXAMPLEKEY123456"
c = new byte[]{69, 88, 65, 77, 80, 76, 69, 75, 69, 89, 49, 50, 51, 52, 53, 54};
// IV → "EXAMPLEIV1234567"
d = new byte[]{69, 88, 65, 77, 80, 76, 69, 73, 86, 49, 50, 51, 52, 53, 54, 55};
69=0x45=E, 88=0x58=X… 후킹으로 뽑은 hex를 10진수로 적은 것뿐, 값은 동일하다.
눈여겨볼 건 문자열 리터럴이 아니라 바이트 배열로 풀어 썼다는 점이다. 문자열 검색을 피하려는 난독화 의도로 보인다.
하지만 배열도 ASCII로 읽으면 그대로 드러나니 우회가 되지 않는다. 오히려 "일부러 숨기려 한 정황"이라 지적 포인트가 하나 더 생긴다.
✅ 증거 체인 완성
동적(런타임 키·IV) → 정적(소스 선언 위치·클래스명) → 바이트 일치.
세 겹이 맞물리면 "런타임 우연"이라는 반박이 불가능해진다.
05 · iOS — 같은 기법, 다른 함수
iOS에서도 원리는 똑같다. 다만 iOS는 암호화 API가 한 군데로 모이지 않는다는 점이 다르다.
네이티브 AES는 대부분 CommonCrypto의 CCCrypt C 함수로 흘러간다. Android의 Cipher.init에 대응하는 자리가 바로 여기다.
차이는 후킹 방식이다. 자바 클래스가 아니라 C 함수라서 Interceptor.attach로 네이티브 함수를 가로챈다. 핵심은 이 함수의 인자 순서를 아는 것이다.
CCCrypt(op, alg, options, key, keyLength, iv, ...)
a0 a1 a2 a3 a4 a54번째 인자(a[3])가 키가 담긴 메모리 주소, 6번째(a[5])가 IV 주소다.
이 함수가 호출되는 순간 그 주소에서 각각 keyLen바이트, 16바이트를 읽으면 된다.
(function () {
var seen = {}; // 같은 key/IV 반복 출력 방지
function hook(name) {
var p = Module.findExportByName(null, name);
if (!p) { console.log('[-] ' + name + ' 없음'); return; }
Interceptor.attach(p, {
onEnter: function (a) {
try {
var keyLen = a[4].toInt32();
if (keyLen <= 0 || keyLen > 32) return; // 비정상 길이 방어
// a[3] = key 포인터, a[5] = IV 포인터
var kb = a[3].readByteArray(keyLen);
if (!kb) return;
var keyHex = '', u = new Uint8Array(kb);
for (var i = 0; i < u.length; i++)
keyHex += ('0' + u[i].toString(16)).slice(-2);
var ivHex = '';
if (!a[5].isNull()) {
var v = new Uint8Array(a[5].readByteArray(16));
for (var j = 0; j < v.length; j++)
ivHex += ('0' + v[j].toString(16)).slice(-2);
}
var sig = name + keyHex + ivHex;
if (seen[sig]) return; // 중복 스킵 → 로그 폭주 방지
seen[sig] = true;
console.log('\n[' + name + '] keyLen=' + keyLen);
console.log(' key(hex)=' + keyHex);
console.log(' IV (hex)=' + (ivHex || '(none)'));
} catch (e) { console.log('[' + name + '] err: ' + e); }
}
});
console.log('[+] ' + name + ' 후킹 완료');
}
hook('CCCrypt'); // 원샷 암호화
hook('CCCryptorCreate'); // 스트리밍 암호화
})();
Android와 동일하게 인증번호를 입력하자, CCCrypt가 호출되며 값이 떨어졌다.
[+] CCCrypt 후킹 완료
[+] CCCryptorCreate 후킹 완료
[CCCrypt] keyLen=16
key(hex)=4558414d504c454b4559313233343536
IV (hex)=4558414d504c45495631323334353637Android에서 본 값과 바이트 하나까지 동일하다.
양 플랫폼 빌드가 같은 하드코딩 키·IV를 공유한다는 뜻이고, 이는 플랫폼과 무관하게 CBC 암호문을 전부 복호화할 수 있다는 의미다.
💡 한 가지 실전 포인트
처음엔
hexdump를 직접 쓰는 짧은 후킹을 썼는데, 유효하지 않은 메모리를 읽다가 세션이 멈추는 일이 있었다. 위처럼readByteArray+ 길이 체크 +try/catch+ 중복 제거를 넣으면 안정적으로 찍힌다. 로그 폭주도 함께 막힌다.
06 · 정리 — 대응과 핵심
이 CBC 경로의 문제는 세 겹이다.
- 하드코딩 AES-128 키 — 앱 소지자 누구나 복호화 가능
- 고정 IV — CBC의 의미론적 안전성 상실, 평문 패턴 노출
- 바이트 배열 난독화 — 은폐 시도 정황
흥미로운 건 같은 앱의 Keystore 기반 GCM 경로는 제대로 짜여 있었다는 점이다. 레거시 CBC만 구멍으로 남은 전형적인 split 아키텍처였다.
| 항목 | Android | iOS |
|---|---|---|
| 후킹 지점 | javax.crypto.Cipher.init |
CCCrypt / CCCryptorCreate |
| 방식 | Java 메서드 implementation 교체 | 네이티브 함수 Interceptor.attach |
| 키 위치 | Key.getEncoded() |
인자 a[3] 메모리 |
| IV 위치 | IvParameterSpec / getIV() |
인자 a[5] 메모리 |
대응방안
- 하드코딩 키 제거 — 소스 상수 대신 Android Keystore / iOS Keychain·Secure Enclave 기반의 비추출 키를 사용하거나, 안전한 키 교환으로 관리한다.
- 고정 IV 제거 — 매 암호화마다
SecureRandom으로 IV를 새로 생성하고, 암호문 앞에 붙여 전달한다. - AEAD로 전환 — 무결성까지 보장하는
AES/GCM으로 통일한다. 같은 앱의 Keystore GCM 경로가 이미 좋은 선례다.
기억할 한 가지
암호화는 키를 숨길 수 없다.
암호화하는 순간 키는 반드시 함수로 전달되고, 그 함수는 메모리 위에 있다. Android면 Cipher.init, iOS면 CCCrypt — 그 한 지점에 덫을 놓고 기능을 트리거하면, 앱이 무엇을 숨기든 키는 드러난다.
그리고 뽑은 바이트를 ASCII로 한 번 읽어보는 것, 그 30초가 하드코딩을 잡는 가장 빠른 길이다.
#Frida #AES/CBC #HardcodedKey #CommonCrypto #jadx #MobilePentest
개인 학습 기록 · 패키지명·도메인·키 값은 전부 예시로 치환했으며, 기법 자체는 실제 진단에서 쓴 것과 동일하다.