문제: DreamHack — [CodeEngn] MobileApp L01 분류: Reversing / Mobile (Android) 난이도: 🥉 Bronze 3 FLAG:
H3ll0 C0de3ngn
APK 하나가 주어지고 설명은 딱 한 줄, "키값을 찾으시오". 앱을 켜서 버튼을 누르면 대화상자에
Key 라는 제목과 함께 어떤 문자열이 뜨는데, 그 문자열이 정답이다. 굳이 에뮬레이터를 띄우지
않아도 코드만 정적으로 읽으면 그 값을 그대로 계산해낼 수 있다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | [CodeEngn] MobileApp L01 |
| 난이도 | 🥉 Bronze 3 |
| 분류 | Reversing / Mobile |
| 제공 파일 | SmartApp L01.apk |
| 스택 | Android (Dalvik), 네이티브 libKISACrypto.so |
| 핵심 기법 | SEED-128 CBC 복호 + Java BigInteger 부호 인코딩 해독 |
풀이 흐름은 짧다. APK 를 디컴파일해서 버튼 콜백이 무엇을 하는지 본다. Security.DecryptStr()
안에서 하드코딩된 hex 문자열을 SEED 로 복호한다는 걸 확인하고, 같은 클래스의 정적 배열에서
key 와 IV 를 꺼낸 다음, 그 암호문을 그대로 복호하면 끝이다. 걸림돌은 딱 두 개 —
암호문이 BigInteger 로 인코딩돼 있다는 점과, key 와 IV 를 헷갈리기 쉽다는 점이다.
🧩 배경 — KISA SEED, 그리고 이 앱의 정체
SEED 는 한국인터넷진흥원(KISA)이 만든 128비트 블록 암호다. AES 와 같은 16바이트 블록에
128비트 키를 쓰고, 이 문제에서는 CBC(Cipher Block Chaining) 운용 모드로 동작한다. 앱은
KISA 가 배포하는 레퍼런스 구현을 그대로 가져다 썼다 — Java 래퍼 클래스 org.kisa.SEED 가
init / process / close 3단계 API 를 제공하고, 실제 블록 연산은 네이티브 라이브러리에
들어 있다.
// org/kisa/SEED.java — 실연산은 전부 native (libKISACrypto.so)
private native int internalSeedCBCProcessDec(int i, int[] iArr, ...);
private native int internalSeedProcessBlocks(int[] iArr, ...);
public int init(int enc, byte[] key, byte[] iv) { ... } // enc: 1=암호화, 0=복호
public int process(byte[] in, int inLen, byte[] out, int off) { ... }여기서 중요한 건, 네이티브에 숨겨진 게 무슨 커스텀 로직이 아니라 표준 SEED-128 이라는
점이다. 표준이면 굳이 .so 를 리버싱할 필요 없이 openssl 의 seed-cbc 로 그대로 복호할 수
있다. System.loadLibrary("KISACrypto") 는 눈길을 끌지만 결국 미끼에 가깝다.
🔬 코드 정찰
먼저 APK 안에 뭐가 들었는지 본다. .apk 는 사실 zip 이라 그냥 목록만 봐도 구조가 보인다.
file "SmartApp L01.apk"
unzip -l "SmartApp L01.apk" | grep -E "dex|\.so|MANIFEST"
class.dex(앱 바이트코드)와 lib/armeabi/libKISACrypto.so(SEED 네이티브 구현) 두 개가
핵심이다. dex 를 사람이 읽을 수 있는 Java 로 되돌리기 위해 jadx 로 디컴파일한다.
jadx -q -r -d out "SmartApp L01.apk"
sed -n '6,45p' out/sources/com/namdaehyeon/findkey1/Security.java
Security 클래스 전체가 이 문제의 답지다. 정적 초기화 블록에 key 와 iv 가 바이트 배열로
박혀 있고, DecryptStr() 가 복호 로직이다. 코드를 그대로 옮기면 이렇다.
public class Security {
static {
System.loadLibrary("KISACrypto");
key = new byte[]{51, -46, 79, -113, 8, 34, 121, -15, -23, -13, -108, 55, 10, -44, 5, -119};
iv = new byte[]{38, -115, 102, -89, 53, -88, 26, -127, 95, -70, -39, -6, 54, 25, 37, 19};
}
public static String DecryptStr(String decText) {
if (decText == null || decText.equals("")) return "";
byte[] cipherText = new BigInteger(decText.trim(), 16).toByteArray(); // ← hex → 바이트
byte[] plainText = new byte[144];
SEED seed = new SEED();
seed.init(0, key, iv); // ← 0 = 복호 모드
int outputTextLen = seed.process(cipherText, cipherText.length, plainText, 0);
seed.close(plainText, outputTextLen);
return new String(plainText).trim();
}
}그럼 이 DecryptStr() 에 실제로 어떤 문자열이 들어가는지가 남았다. 버튼 콜백을 따라가면
MainActivity 의 onClick 이 인자를 직접 넘긴다.
// MainActivity$1.onClick — 버튼을 누르면
alert.setTitle("Key");
alert.setMessage(Security.DecryptStr(
"-1aaa755a1e60915baff1d4cb64cb221a0000000000000000000000000000"));
alert.show();정리하면 목표는 명확하다. 위 hex 문자열을 BigInteger 로 바이트로 바꾸고, key/iv 로
SEED-128 CBC 복호를 돌리면 대화상자에 뜨는 그 "키값"이 나온다.
🐛 삽질 — IV 를 key 로 착각해서 계속 깨졌다
처음엔 key 와 IV 를 대충 눈으로 긁어서 openssl 에 넣었는데 결과가 계속 깨진 바이트로 나왔다.
CBC 복호는 첫 블록 평문이 D(C1) ⊕ IV 라, IV 가 한 바이트라도 틀리면 그 블록 전체가
쓰레기가 된다. 이 문제는 유효 블록이 딱 하나뿐이라 IV 를 틀리는 순간 답 전체가 날아간다.
▶🐛 삽질 — 잘못된 IV로 돌린 첫 복호 (garbage)
배열이 두 개(array_0, array_1) 있는데, 초기 추출에서 IV 자리에 실수로 key 값을 그대로
복사해 두 값이 같다고 착각했다. 그 상태로 복호하면 이렇게 의미 없는 바이트가 나온다.
# 잘못된 IV(=key)로 복호 → garbage
$ printf 'e5558aa5e19f6ea4500e2b349b34dde6' | xxd -r -p | \
openssl enc -d -seed-cbc -nopad -provider legacy \
-K 33d24f8f082279f1e9f394370ad40589 \
-iv 33d24f8f082279f1e9f394370ad40589 | xxd
00000000: 9b1e 7d2c ... (읽을 수 없는 바이트)해결은 단순했다. jadx 가 보여준 두 배열을 각각 부호 없는 바이트로 다시 디코딩해서 정말 다른 값인지 확인했다.
sbyte = lambda arr: bytes(x & 0xFF for x in arr).hex()
key = [51, -46, 79, -113, 8, 34, 121, -15, -23, -13, -108, 55, 10, -44, 5, -119]
iv = [38, -115, 102, -89, 53, -88, 26, -127, 95, -70, -39, -6, 54, 25, 37, 19]
print("key =", sbyte(key))
print("iv =", sbyte(iv))
print("key == iv ?", sbyte(key) == sbyte(iv))
key == iv ? False. IV 는 268d66a735a81a815fbad9fa36192513 으로 key 와 완전히 다른
값이었다. 이걸 바로잡으니 복호가 한 번에 풀렸다.
💣 핵심 — BigInteger 부호 인코딩과 진짜 IV
두 지점을 정확히 짚으면 나머지는 기계적이다.
1) 암호문이 BigInteger 의 2의 보수로 인코딩돼 있다. DecryptStr 에 넘어온 문자열은
맨 앞에 - 가 붙은 음수 hex 다. Java new BigInteger("-1aaa…0000", 16) 은 이걸 음수로
파싱하고, .toByteArray() 는 최소 길이의 부호 있는 2의 보수 big-endian 을 돌려준다.
원래 문자열의 크기 부분은 1aaa755a1e60915baff1d4cb64cb221a 뒤에 0 이 14바이트 붙은 30바이트
값이다. 음수라서 2의 보수를 취하면 상위 16바이트가 뒤집혀 e5558aa5e19f6ea4500e2b349b34dde6
가 되고, 하위 14바이트는 그대로 0 이 된다. 즉 실제 SEED 로 들어가는 암호문은
e5558aa5…dde6 한 블록(16B)이 전부고, 뒤따르는 0 바이트들은 블록을 못 채워 버려진다.
2) CBC 복호의 첫 블록은 IV 에 전적으로 의존한다.

출처: Wikimedia Commons, WhiteTimberwolf, public domain
그림처럼 첫 평문 블록은 블록복호(C1) ⊕ IV 다. 유효 블록이 하나뿐인 이 문제에서 IV 를
틀리면 답 전체가 어긋난다. 앞의 삽질이 정확히 이 함정이었다.
전체를 한 장으로 정리하면 이렇다.

🎯 복호 실행
표준 SEED 이므로 .so 를 건드릴 것도 없이 openssl 의 seed-cbc 로 첫 블록만 복호한다.
패딩은 아직 붙어 있으니 -nopad 로 그대로 받아 눈으로 확인한다. (SEED 는 레거시 알고리즘이라
-provider legacy 를 함께 지정해야 한다.)
printf 'e5558aa5e19f6ea4500e2b349b34dde6' | xxd -r -p | \
openssl enc -d -seed-cbc -nopad -provider legacy -provider default \
-K 33d24f8f082279f1e9f394370ad40589 \
-iv 268d66a735a81a815fbad9fa36192513 | xxd
48 33 6c 6c 30 20 43 30 64 65 33 6e 67 6e 이 ASCII 로 H3ll0 C0de3ngn 이고, 뒤에
02 02 가 붙어 있다. 14바이트 평문을 16바이트 블록에 맞추느라 PKCS#7 이 2바이트를 채운
것이다. 원본 코드가 new String(plainText).trim() 으로 뒤를 잘라내는 이유가 여기 있다.
🚀 Full Exploit
앞의 두 함정(BigInteger 인코딩, 진짜 IV)만 코드로 옮기면 APK 하나로 끝나는 자체 완결 스크립트가 된다. smali 에서 key/iv 와 암호문 문자열을 직접 파싱하므로 값을 손으로 옮겨 적을 필요도 없다.
# solve.py — smali 에서 key/iv/암호문을 파싱해 SEED-CBC 로 복호
import re, subprocess
from pathlib import Path
BASE = Path(__file__).resolve().parent / "extracted" / "smali376" / "smali_class"
SEC = BASE / "com" / "namdaehyeon" / "findkey1" / "Security.smali"
MAIN = BASE / "com" / "namdaehyeon" / "findkey1" / "MainActivity$1.smali"
def parse_smali_byte_array(text, label):
m = re.search(r":%s\s*\.array-data 1(.*?)\.end array-data" % label, text, re.S)
vals = re.findall(r"(-?0x[0-9a-fA-F]+)t", m.group(1))
return bytes(int(v, 16) & 0xFF for v in vals)
def java_biginteger_to_bytes(hex_str):
v = int(hex_str, 16) # '-1aaa…' → 음수
if v == 0:
return b"\x00"
n = (v if v > 0 else -v - 1).bit_length() // 8 + 1 # 부호 비트 자리 확보
return v.to_bytes(n, "big", signed=True)
def seed_cbc_decrypt(ct, key, iv):
out = subprocess.run(
["openssl", "enc", "-d", "-seed-cbc",
"-K", key.hex(), "-iv", iv.hex(), "-nopad",
"-provider", "legacy", "-provider", "default"],
input=ct[:16], capture_output=True) # 유효 블록은 선두 16B
return out.stdout
def pkcs7_strip(b):
pad = b[-1]
return b[:-pad] if 1 <= pad <= 16 and b[-pad:] == bytes([pad]) * pad else b
sec = SEC.read_text()
key = parse_smali_byte_array(sec, "array_0")
iv = parse_smali_byte_array(sec, "array_1")
hex_str = re.search(r'const-string v1, "(-?[0-9a-fA-F]+)"', MAIN.read_text()).group(1)
ct = java_biginteger_to_bytes(hex_str)
pt = seed_cbc_decrypt(ct, key, iv)
print(f"[*] key = {key.hex()}")
print(f"[*] iv = {iv.hex()}")
print(f"[*] ciphertext = {ct.hex()}")
print(f"[+] KEY value = {pkcs7_strip(pt).decode(errors='replace')!r}")
앱을 켜서 버튼을 누르면 뜨는 그 대화상자 값이 이것과 같다. 제출할 키값은 H3ll0 C0de3ngn.
📝 결론
표준 알고리즘이면 네이티브를 리버싱할 필요가 없다.
libKISACrypto.so 라는 네이티브 라이브러리가 눈에 걸리지만, 그 안이 표준 SEED-128 인 이상
.so 를 뜯을 이유가 없다. key·IV·암호문만 확보하면 openssl 로 그대로 복호된다. 커스텀
연산이 아니라 표준 구현을 그대로 링크한 앱에서 자주 통하는 지름길이다.
IV 는 key 가 아니다.
CBC 의 첫 블록 평문은 IV 에 전적으로 의존하는데, 이 문제는 유효 블록이 하나뿐이라 IV 한 바이트만 틀려도 답 전체가 깨진다. 배열이 둘 있으면 어느 쪽이 key 고 어느 쪽이 IV 인지 디코딩해서 확실히 구분하는 게 먼저다.
BigInteger 로 인코딩된 암호문은 부호를 조심한다.
Java 의 BigInteger(str, 16).toByteArray() 는 2의 보수라, 최상위 비트가 1 이면 값이 음수로
잡히고 앞뒤가 뒤집힌다. 이 문제의 -1aaa… 처럼 부호가 붙은 hex 는 단순 bytes.fromhex 로는
복원되지 않는다. 인코딩 규칙을 그대로 흉내 내야 원래 바이트가 나온다.
방어 관점에서 보면, 이런 앱의 근본 문제는 키를 앱 안에 그대로 박아둔 것 이다. 대칭키와 IV 가 dex 정적 배열에 상수로 들어 있으면 복호는 시간문제다. 클라이언트에 비밀을 두면 그건 더 이상 비밀이 아니다. 진짜 보호가 필요하면 키는 서버에 두고, 단말에는 하드웨어 기반 키스토어(Android Keystore)처럼 추출이 어려운 저장소를 쓰는 쪽이 맞다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…